diff options
author | Daniel Baumann <daniel@debian.org> | 2024-11-21 15:00:40 +0100 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-11-21 15:00:40 +0100 |
commit | 012d9cb5faed22cb9b4151569d30cc08563b02d1 (patch) | |
tree | fd901b9c231aeb8afa713851f23369fa4a1af2b3 /src | |
parent | Initial commit. (diff) | |
download | pysilfont-012d9cb5faed22cb9b4151569d30cc08563b02d1.tar.xz pysilfont-012d9cb5faed22cb9b4151569d30cc08563b02d1.zip |
Adding upstream version 1.8.0.upstream/1.8.0upstream
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'src')
81 files changed, 15531 insertions, 0 deletions
diff --git a/src/silfont/__init__.py b/src/silfont/__init__.py new file mode 100644 index 0000000..71638fe --- /dev/null +++ b/src/silfont/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2014-2023 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__version__ = '1.8.0' diff --git a/src/silfont/comp.py b/src/silfont/comp.py new file mode 100644 index 0000000..9e97c12 --- /dev/null +++ b/src/silfont/comp.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +'Composite glyph definition' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Rowe' + +import re +from xml.etree import ElementTree as ET + +# REs to parse (from right to left) comment, SIL extension parameters, markinfo, UID, metrics, +# and (from left) glyph name + +# Extract comment from end of line (NB: Doesn't use re.VERBOSE because it contains #.) +# beginning of line, optional whitespace, remainder, optional whitespace, comment to end of line +inputline=re.compile(r"""^\s*(?P<remainder>.*?)(\s*#\s*(?P<commenttext>.*))?$""") + +# Parse SIL extension parameters in [...], but only after | +paraminfo=re.compile(r"""^\s* + (?P<remainder>[^|]* + ($| + \|[^[]*$| + \|[^[]*\[(?P<paraminfo>[^]]*)\])) + \s*$""",re.VERBOSE) + +# Parse markinfo +markinfo=re.compile(r"""^\s* + (?P<remainder>[^!]*?) + \s* + (?:!\s*(?P<markinfo>[.0-9]+(?:,[ .0-9]+){3}))? # ! markinfo + (?P<remainder2>[^!]*?) + \s*$""",re.VERBOSE) + +# Parse uid +uidinfo=re.compile(r"""^\s* + (?P<remainder>[^|]*?) + \s* + (?:\|\s*(?P<UID>[^^!]*)?)? # | followed by nothing, or 4- to 6-digit UID + (?P<remainder2>[^|]*?) + \s*$""",re.VERBOSE) + +# Parse metrics +metricsinfo=re.compile(r"""^\s* + (?P<remainder>[^^]*?) + \s* + (?:\^\s*(?P<metrics>[-0-9]+\s*(?:,\s*[-0-9]+)?))? # metrics (either ^x,y or ^a) + (?P<remainder2>[^^]*?) + \s*$""",re.VERBOSE) + +# Parse glyph information (up to =) +glyphdef=re.compile(r"""^\s* + (?P<PSName>[._A-Za-z][._A-Za-z0-9-]*) # glyphname + \s*=\s* + (?P<remainder>.*?) + \s*$""",re.VERBOSE) + +# break tokens off the right hand side from right to left and finally off left hand side (up to =) +initialtokens=[ (inputline, 'commenttext', ""), + (paraminfo, 'paraminfo', "Error parsing parameters in [...]"), + (markinfo, 'markinfo', "Error parsing information after !"), + (uidinfo, 'UID', "Error parsing information after |"), + (metricsinfo, 'metrics', "Error parsing information after ^"), + (glyphdef, 'PSName', "Error parsing glyph name before =") ] + +# Parse base and diacritic information +compdef=re.compile(r"""^\s* + (?P<compname>[._A-Za-z][._A-Za-z0-9-]*) # name of base or diacritic in composite definition + (?:@ # @ precedes position information + (?:(?:\s*(?P<base>[^: ]+)):)? # optional base glyph followed by : + \s* + (?P<position>(?:[^ +&[])+) # position information (delimited by space + & [ or end of line) + \s*)? # end of @ clause + \s* + (?:\[(?P<params>[^]]*)\])? # parameters inside [..] + \s* + (?P<remainder>.*)$ + """,re.VERBOSE) + +# Parse metrics +lsb_rsb=re.compile(r"""^\s* + (?P<lsb>[-0-9]+)\s*(?:,\s*(?P<rsb>[-0-9]+))? # optional metrics (either ^lsb,rsb or ^adv) + \s*$""",re.VERBOSE) + +# RE to break off one key=value parameter from text inside [key=value;key=value;key=value] +paramdef=re.compile(r"""^\s* + (?P<paramname>[a-z0-9]+) # paramname + \s*=\s* # = (with optional white space before/after) + (?P<paramval>[^;]+?) # any text up to ; or end of string + \s* # optional whitespace + (?:;\s*(?P<rest>.+)$|\s*$) # either ; and (non-empty) rest of parameters, or end of line + """,re.VERBOSE) + +class CompGlyph(object): + + def __init__(self, CDelement=None, CDline=None): + self.CDelement = CDelement + self.CDline = CDline + + def _parseparams(self, rest): + """Parse a parameter line such as: + key1=value1;key2=value2 + and return a dictionary with key:value pairs. + """ + params = {} + while rest: + matchparam=re.match(paramdef,rest) + if matchparam == None: + raise ValueError("Parameter error: " + rest) + params[matchparam.group('paramname')] = matchparam.group('paramval') + rest = matchparam.group('rest') + return(params) + + def parsefromCDline(self): + """Parse the composite glyph information (in self.CDline) such as: + LtnCapADiear = LtnCapA + CombDiaer@U |00C4 ! 1, 0, 0, 1 # comment + and return a <glyph> element (in self.CDelement) + <glyph PSName="LtnCapADiear" UID="00C4"> + <note>comment</note> + <property name="mark" value="1, 0, 0, 1"/> + <base PSName="LtnCapA"> + <attach PSName="CombDiaer" with="_U" at="U"/> + </base> + </glyph> + Position info after @ can include optional base glyph name followed by colon. + """ + line = self.CDline + results = {} + for parseinfo in initialtokens: + if len(line) > 0: + regex, groupname, errormsg = parseinfo + matchresults = re.match(regex,line) + if matchresults == None: + raise ValueError(errormsg) + line = matchresults.group('remainder') + resultsval = matchresults.group(groupname) + if resultsval != None: + results[groupname] = resultsval.strip() + if groupname == 'paraminfo': # paraminfo match needs to be removed from remainder + line = line.rstrip('['+resultsval+']') + if 'remainder2' in matchresults.groupdict().keys(): line += ' ' + matchresults.group('remainder2') +# At this point results optionally may contain entries for any of 'commenttext', 'paraminfo', 'markinfo', 'UID', or 'metrics', +# but it must have 'PSName' if any of 'paraminfo', 'markinfo', 'UID', or 'metrics' present + note = results.pop('commenttext', None) + if 'PSName' not in results: + if len(results) > 0: + raise ValueError("Missing glyph name") + else: # comment only, or blank line + return None + dic = {} + UIDpresent = 'UID' in results + if UIDpresent and results['UID'] == '': + results.pop('UID') + if 'paraminfo' in results: + paramdata = results.pop('paraminfo') + if UIDpresent: + dic = self._parseparams(paramdata) + else: + line += " [" + paramdata + "]" + mark = results.pop('markinfo', None) + if 'metrics' in results: + m = results.pop('metrics') + matchmetrics = re.match(lsb_rsb,m) + if matchmetrics == None: + raise ValueError("Error in parameters: " + m) + elif matchmetrics.group('rsb'): + metricdic = {'lsb': matchmetrics.group('lsb'), 'rsb': matchmetrics.group('rsb')} + else: + metricdic = {'advance': matchmetrics.group('lsb')} + else: + metricdic = None + + # Create <glyph> element and assign attributes + g = ET.Element('glyph',attrib=results) + if note: # note from commenttext becomes <note> subelement + n = ET.SubElement(g,'note') + n.text = note.rstrip() + # markinfo becomes <property> subelement + if mark: + p = ET.SubElement(g, 'property', name = 'mark', value = mark) + # paraminfo parameters (now in dic) become <property> subelements + if dic: + for key in dic: + p = ET.SubElement(g, 'property', name = key, value = dic[key]) + # metrics parameters (now in metricdic) become subelements + if metricdic: + for key in metricdic: + k = ET.SubElement(g, key, width=metricdic[key]) + + # Prepare to parse remainder of line + prevbase = None + prevdiac = None + remainder = line + expectingdiac = False + + # top of loop to process remainder of line, breaking off base or diacritics from left to right + while remainder != "": + matchresults=re.match(compdef,remainder) + if matchresults == None or matchresults.group('compname') == "" : + raise ValueError("Error parsing glyph name: " + remainder) + propdic = {} + if matchresults.group('params'): + propdic = self._parseparams(matchresults.group('params')) + base = matchresults.group('base') + position = matchresults.group('position') + if expectingdiac: + # Determine parent element, based on previous base and diacritic glyphs and optional + # matchresults.group('base'), indicating diacritic attaches to a different glyph + if base == None: + if prevdiac != None: + parent = prevdiac + else: + parent = prevbase + elif base != prevbase.attrib['PSName']: + raise ValueError("Error in diacritic alternate base glyph: " + base) + else: + parent = prevbase + if prevdiac == None: + raise ValueError("Unnecessary diacritic alternate base glyph: " + base) + # Because 'with' is Python reserved word, passing it directly as a parameter + # causes Python syntax error, so build dictionary to pass to SubElement + att = {'PSName': matchresults.group('compname')} + if position: + if 'with' in propdic: + withval = propdic.pop('with') + else: + withval = "_" + position + att['at'] = position + att['with'] = withval + # Create <attach> subelement + e = ET.SubElement(parent, 'attach', attrib=att) + prevdiac = e + elif (base or position): + raise ValueError("Position information on base glyph not supported") + else: + # Create <base> subelement + e = ET.SubElement(g, 'base', PSName=matchresults.group('compname')) + prevbase = e + prevdiac = None + if 'shift' in propdic: + xval, yval = propdic.pop('shift').split(',') + s = ET.SubElement(e, 'shift', x=xval, y=yval) + # whatever parameters are left in propdic become <property> subelements + for key, val in propdic.items(): + p = ET.SubElement(e, 'property', name=key, value=val) + + remainder = matchresults.group('remainder').lstrip() + nextchar = remainder[:1] + remainder = remainder[1:].lstrip() + expectingdiac = nextchar == '+' + if nextchar == '&' or nextchar == '+': + if len(remainder) == 0: + raise ValueError("Expecting glyph name after & or +") + elif len(nextchar) > 0: + raise ValueError("Expecting & or + and found " + nextchar) + self.CDelement = g + + def _diacinfo(self, node, parent, lastglyph): + """receives attach element, PSName of its parent, PSName of most recent glyph + returns a string equivalent of this node (and all its descendants) + and a string with the name of the most recent glyph + """ + diacname = node.get('PSName') + atstring = node.get('at') + withstring = node.get('with') + propdic = {} + if withstring != "_" + atstring: + propdic['with'] = withstring + subattachlist = [] + attachglyph = "" + if parent != lastglyph: + attachglyph = parent + ":" + for subelement in node: + if subelement.tag == 'property': + propdic[subelement.get('name')] = subelement.get('value') + elif subelement.tag == 'attach': + subattachlist.append(subelement) + elif subelement.tag == 'shift': + propdic['shift'] = subelement.get('x') + "," + subelement.get('y') + # else flag error/warning? + propstring = "" + if propdic: + propstring += " [" + ";".join( [k + "=" + v for k,v in propdic.items()] ) + "]" + returnstring = " + " + diacname + "@" + attachglyph + atstring + propstring + prevglyph = diacname + for s in subattachlist: + string, prevglyph = self._diacinfo(s, diacname, prevglyph) + returnstring += string + return returnstring, prevglyph + + def _basediacinfo(self, baseelement): + """receives base element and returns a string equivalent of this node (and all its desendants)""" + basename = baseelement.get('PSName') + returnstring = basename + prevglyph = basename + bpropdic = {} + for child in baseelement: + if child.tag == 'attach': + string, prevglyph = self._diacinfo(child, basename, prevglyph) + returnstring += string + elif child.tag == 'shift': + bpropdic['shift'] = child.get('x') + "," + child.get('y') + if bpropdic: + returnstring += " [" + ";".join( [k + "=" + v for k,v in bpropdic.items()] ) + "]" + return returnstring + + def parsefromCDelement(self): + """Parse a glyph element such as: + <glyph PSName="LtnSmITildeGraveDotBlw" UID="E000"> + <note>i tilde grave dot-below</note> + <base PSName="LtnSmDotlessI"> + <attach PSName="CombDotBlw" at="L" with="_L" /> + <attach PSName="CombTilde" at="U" with="_U"> + <attach PSName="CombGrave" at="U" with="_U" /> + </attach> + </base> + </glyph> + and produce the equivalent CDline in format: + LtnSmITildeGraveDotBlw = LtnSmDotlessI + CombDotBlw@L + CombTilde@LtnSmDotlessI:U + CombGrave@U | E000 # i tilde grave dot-below + """ + g = self.CDelement + lsb = None + rsb = None + adv = None + markinfo = None + note = None + paramdic = {} + outputline = [g.get('PSName')] + resultUID = g.get('UID') + basesep = " = " + + for child in g: + if child.tag == 'note': note = child.text + elif child.tag == 'property': + if child.get('name') == 'mark': markinfo = child.get('value') + else: paramdic[child.get('name')] = child.get('value') + elif child.tag == 'lsb': lsb = child.get('width') + elif child.tag == 'rsb': rsb = child.get('width') + elif child.tag == 'advance': adv = child.get('width') + elif child.tag == 'base': + outputline.extend([basesep, self._basediacinfo(child)]) + basesep = " & " + + if paramdic and resultUID == None: + resultUID = " " # to force output of | + if adv: outputline.extend([' ^', adv]) + if lsb and rsb: outputline.extend([' ^', lsb, ',', rsb]) + if resultUID: outputline.extend([' |', resultUID]) + if markinfo: outputline.extend([' !', markinfo]) + if paramdic: + paramsep = " [" + for k in paramdic: + outputline.extend([paramsep, k, "=", paramdic[k]]) + paramsep = ";" + outputline.append("]") + if note: + outputline.extend([" # ", note]) + self.CDline = "".join(outputline) + diff --git a/src/silfont/core.py b/src/silfont/core.py new file mode 100644 index 0000000..b4b3efe --- /dev/null +++ b/src/silfont/core.py @@ -0,0 +1,754 @@ +#!/usr/bin/env python3 +'General classes and functions for use in pysilfont scripts' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2014-2023 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from glob import glob +from collections import OrderedDict +import sys, os, argparse, datetime, shutil, csv, configparser + +import silfont + +class loggerobj(object): + # For handling log messages. + # Use S for severe errors caused by data, parameters supplied by user etc + # Use X for severe errors caused by bad code to get traceback exception + + def __init__(self, logfile=None, loglevels="", leveltext="", loglevel="W", scrlevel="P"): + self.logfile = logfile + self.loglevels = loglevels + self.leveltext = leveltext + self.errorcount = 0 + self.warningcount = 0 + if not self.loglevels: self.loglevels = {'X': 0, 'S': 1, 'E': 2, 'P': 3, 'W': 4, 'I': 5, 'V': 6} + if not self.leveltext: self.leveltext = ('Exception ', 'Severe: ', 'Error: ', 'Progress: ', 'Warning: ', 'Info: ', 'Verbose: ') + super(loggerobj, self).__setattr__("loglevel", "E") # Temp values so invalid log levels can be reported + super(loggerobj, self).__setattr__("scrlevel", "E") # + self.loglevel = loglevel + self.scrlevel = scrlevel + + def __setattr__(self, name, value): + if name in ("loglevel", "scrlevel"): + if value in self.loglevels: + (minlevel, minnum) = ("E",2) if name == "loglevel" else ("S", 1) + if self.loglevels[value] < minnum: + value = minlevel + self.log(name + " increased to minimum level of " + minlevel, "E") + else: + self.log("Invalid " + name + " value: " + value, "S") + super(loggerobj, self).__setattr__(name, value) + if name == "scrlevel" : self._basescrlevel = value # Used by resetscrlevel + + def log(self, logmessage, msglevel="W"): + levelval = self.loglevels[msglevel] + message = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S ") + self.leveltext[levelval] + str(logmessage) + #message = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[0:22] +" "+ self.leveltext[levelval] + logmessage ## added milliseconds for timing tests + if levelval <= self.loglevels[self.scrlevel]: print(message) + if self.logfile and levelval <= self.loglevels[self.loglevel]: self.logfile.write(message + "\n") + if msglevel == "S": + print("\n **** Fatal error - exiting ****") + sys.exit(1) + if msglevel == "X": assert False, message + if msglevel == "E": self.errorcount += 1 + if msglevel == "W": self.warningcount += 1 + + def raisescrlevel(self, level): # Temporarily increase screen logging + if level not in self.loglevels or level == "X" : self.log("Invalid scrlevel: " + level, "X") + if self.loglevels[level] > self.loglevels[self.scrlevel]: + current = self.scrlevel + self.scrlevel = level + self._basescrlevel = current + self.log("scrlevel raised to " + level, "I") + + def resetscrlevel(self): + self.scrlevel = self._basescrlevel + + +class parameters(object): + # Object for holding parameters information, organised by class (eg logging) + + # Default parameters for use in pysilfont modules + # Names must be case-insensitively unique across all parameter classes + # Parameter types are deduced from the default values + + def __init__(self): + # Default parameters for all modules + defparams = {} + defparams['system'] = {'version': silfont.__version__, 'copyright': silfont.__copyright__} # Code treats these as read-only + defparams['logging'] = {'scrlevel': 'P', 'loglevel': 'W'} + defparams['backups'] = {'backup': True, 'backupdir': 'backups', 'backupkeep': 5} + # Default parameters for UFO module + defparams['outparams'] = OrderedDict([ # Use ordered dict so parameters show in logical order with -h p + ("UFOversion", ""), # UFOversion - defaults to existing unless a value is supplied + ("indentIncr", " "), # XML Indent increment + ("indentFirst", " "), # First XML indent + ("indentML", False), # Should multi-line string values be indented? + ("plistIndentFirst", ""), # First indent amount for plists + ('precision', 6), # Decimal precision to use in XML output - both for real values and for attributes if float + ("floatAttribs", ['xScale', 'xyScale', 'yxScale', 'yScale', 'angle']), # Used with precision above + ("intAttribs", ['pos', 'width', 'height', 'xOffset', 'yOffset', 'x', 'y']), + ("sortDicts", True), # Should dict elements be sorted alphabetically? + ("renameGlifs", True), # Rename glifs based on UFO3 suggested algorithm + ("format1Glifs", False), # Force output format 1 glifs including UFO2-style anchors (for use with FontForge + ("glifElemOrder", ['advance', 'unicode', 'note', 'image', 'guideline', 'anchor', 'outline', 'lib']), # Order to output glif elements + ("attribOrders.glif",['pos', 'width', 'height', 'fileName', 'base', 'xScale', 'xyScale', 'yxScale', 'yScale', 'xOffset', 'yOffset', + 'x', 'y', 'angle', 'type', 'smooth', 'name', 'format', 'color', 'identifier']) + ]) + defparams['ufometadata'] = {"checkfix": "check"} # Apply metadata fixes when reading UFOs + + self.paramshelp = {} # Info used when outputting help about parame options + self.paramshelp["classdesc"] = { + "logging": "controls the level of log messages go to screen or log files.", + "backups": "controls backup settings for scripts that output fonts - by default backups are made if the output font is overwriting the input font", + "outparams": "Output options for UFOs - cover UFO version and normalization", + "ufometadata": "controls if UFO metadata be checked, or checked and fixed" + } + self.paramshelp["paramsdesc"] = { + "scrlevel": "Logging level for screen messages - one of S,E,P.W,I or V", + "loglevel": "Logging level for log file messages - one of E,P.W,I or V", + "backup": "Should font backups be made", + "backupdir": "Directory to use for font backups", + "backupkeep": "How many backups to keep", + "indentIncr": "XML Indent increment", + "indentFirst": "First XML indent", + "indentML": "Should multi-line string values be indented?", + "plistIndentFirst": "First indent amount for plists", + "sortDicts": "Should dict elements be sorted alphabetically?", + "precision": "Decimal precision to use in XML output - both for real values and for attributes if numeric", + "renameGlifs": "Rename glifs based on UFO3 suggested algorithm", + "UFOversion": "UFOversion to output - defaults to version of the input UFO", + "format1Glifs": "Force output format 1 glifs including UFO2-style anchors (was used with FontForge; no longer needed)", + "glifElemOrder": "Order to output glif elements", + "floatAttribs": "List of float attributes - used when setting decimal precision", + "intAttribs": "List of attributes that should be integers", + "attribOrders.glif": "Order in which to output glif attributes", + "checkfix": "Should check & fix tests be done - one of None, Check or Fix" + } + self.paramshelp["defaultsdesc"] = { # For use where default needs clarifying with text + "indentIncr" : "<two spaces>", + "indentFirst": "<two spaces>", + "plistIndentFirst": "<No indent>", + "UFOversion": "<Existing version>" + } + + self.classes = {} # Dictionary containing a list of parameters in each class + self.paramclass = {} # Dictionary of class name for each parameter name + self.types = {} # Python type for each parameter deduced from initial values supplied + self.listtypes = {} # If type is dict, the type of values in the dict + self.logger = loggerobj() + defset = _paramset(self, "default", "defaults") + self.sets = {"default": defset} + self.lcase = {} # Lower case index of parameters names + for classn in defparams: + self.classes[classn] = [] + for parn in defparams[classn]: + value = defparams[classn][parn] + self.classes[classn].append(parn) + self.paramclass[parn] = classn + self.types[parn] = type(value) + if type(value) is list: self.listtypes[parn] = type(value[0]) + super(_paramset, defset).__setitem__(parn, value) # __setitem__ in paramset does not allow new values! + self.lcase[parn.lower()] = parn + + def addset(self, name, sourcedesc=None, inputdict=None, configfile=None, copyset=None): + # Create a subset from one of a dict, config file or existing set + # Only one option should used per call + # sourcedesc should be added for user-supplied data (eg config file) for reporting purposes + dict = {} + if configfile: + config = configparser.ConfigParser() + config.read_file(open(configfile, encoding="utf-8")) + if sourcedesc is None: sourcedesc = configfile + for classn in config.sections(): + for item in config.items(classn): + parn = item[0] + if self.paramclass[parn] == "system": + self.logger.log("Can't change " + parn + " parameter via config file", "S") + val = item[1].strip('"').strip("'") + dict[parn] = val + elif copyset: + if sourcedesc is None: sourcedesc = "Copy of " + copyset + for parn in self.sets[copyset]: + dict[parn] = self.sets[copyset][parn] + elif inputdict: + dict = inputdict + if sourcedesc is None: sourcedesc = "unspecified source" + self.sets[name] = _paramset(self, name, sourcedesc, dict) + + def printhelp(self): + phelp = self.paramshelp + print("\nMost pysilfont scripts have -p, --params options which can be used to change default behaviour of scripts. For example '-p scrlevel=w' will log warning messages to screen \n") + print("Listed below are all such parameters, grouped by purpose. Not all apply to all scripts - " + "in partucular outparams and ufometadata only apply to scripts using pysilfont's own UFO code") + for classn in ("logging", "backups", "ufometadata", "outparams"): + print("\n" + classn[0].upper() + classn[1:] + " - " + phelp["classdesc"][classn]) + for param in self.classes[classn]: + if param == "format1Glifs": continue # Param due to be phased out + paramdesc = phelp["paramsdesc"][param] + paramtype = self.types[param].__name__ + defaultdesc = phelp["defaultsdesc"][param] if param in phelp["defaultsdesc"] else self.sets["default"][param] + print(' {:<20}: {}'.format(param, paramdesc)) + print(' (Type: {:<6} Default: {})'.format(paramtype + ",", defaultdesc)) + print("\nNote parameter names are case-insensitive\n") + print("For more help see https://github.com/silnrsi/pysilfont/blob/master/docs/parameters.md\n") + +class _paramset(dict): + # Set of parameter values + def __init__(self, params, name, sourcedesc, inputdict=None): + if inputdict is None: inputdict = {} + self.name = name + self.sourcedesc = sourcedesc # Description of source for reporting + self.params = params # Parent parameters object + for parn in inputdict: + if params.paramclass[parn] == "system": # system values can't be changed + if inputdict[parn] != params.sets["default"][parn]: + self.params.logger.log("Can't change " + parn + " - system parameters can't be changed", "X") + else: + super(_paramset, self).__setitem__(parn, inputdict[parn]) + else: + self[parn] = inputdict[parn] + + def __setitem__(self, parn, value): + origvalue = value + origparn = parn + parn = parn.lower() + if self.params.paramclass[origparn] == "system": + self.params.logger.log("Can't change " + parn + " - system parameters are read-only", "X") + if parn not in self.params.lcase: + self.params.logger.log("Invalid parameter " + origparn + " from " + self.sourcedesc, "S") + else: + parn = self.params.lcase[parn] + ptyp = self.params.types[parn] + if ptyp is bool: + value = str2bool(value) + if value is None: self.params.logger.log(self.sourcedesc+" parameter "+origparn+" must be boolean: " + origvalue, "S") + if ptyp is list: + if type(value) is not list: value = value.split(",") # Convert csv string into list + if len(value) < 2: self.params.logger.log(self.sourcedesc+" parameter "+origparn+" must have a list of values: " + origvalue, "S") + valuesOK = True + listtype = self.params.listtypes[parn] + for i, val in enumerate(value): + if listtype is bool: + val = str2bool(val) + if val is None: self.params.logger.log (self.sourcedesc+" parameter "+origparn+" must contain boolean values: " + origvalue, "S") + value[i] = val + if type(val) != listtype: + valuesOK = False + badtype = str(type(val)) + if not valuesOK: self.params.logger.log("Invalid "+badtype+" parameter type for "+origparn+": "+self.params.types[parn], "S") + if parn in ("loglevel", "scrlevel"): # Need to check log level is valid before setting it since otherwise logging will fail + value = value.upper() + if value not in self.params.logger.loglevels: self.params.logger.log (self.sourcedesc+" parameter "+parn+" invalid", "S") + super(_paramset, self).__setitem__(parn, value) + + def updatewith(self, update, sourcedesc=None, log=True): + # Update a set with values from another set + if sourcedesc is None: sourcedesc = self.params.sets[update].sourcedesc + for parn in self.params.sets[update]: + oldval = self[parn] if parn in self else "" + self[parn] = self.params.sets[update][parn] + if log and oldval != "" and self[parn] != oldval: + old = str(oldval) + new = str(self[parn]) + if old != old.strip() or new != new.strip(): # Add quotes if there are leading or trailing spaces + old = '"'+old+'"' + new = '"'+new+'"' + self.params.logger.log(sourcedesc + " parameters: changing "+parn+" from " + old + " to " + new, "I") + + +class csvreader(object): # Iterator for csv files, skipping comments and checking number of fields + def __init__(self, filename, minfields=0, maxfields=999, numfields=None, logger=None): + self.filename = filename + self.minfields = minfields + self.maxfields = maxfields + self.numfields = numfields + self.logger = logger if logger else loggerobj() # If no logger supplied, will just log to screen + # Open the file and create reader + try: + file = open(filename, "rt", encoding="utf-8") + except Exception as e: + print(e) + sys.exit(1) + self.file = file + self.reader = csv.reader(file) + # Find the first non-comment line then reset so __iter__ still returns the first line + # This is so scripts can analyse first line (eg to look for headers) before starting iterating + self.firstline = None + self._commentsbeforefirstline = -1 + while not self.firstline: + row = next(self.reader, None) + if row is None: logger.log("Input csv is empty or all lines are comments or blank", "S") + self._commentsbeforefirstline += 1 + if row == []: continue # Skip blank lines + if row[0].lstrip().startswith("#"): continue # Skip comments - ie lines starting with # + self.firstline = row + file.seek(0) # Reset the csv and skip comments + for i in range(self._commentsbeforefirstline): next(self.reader, None) + + def __setattr__(self, name, value): + if name == "numfields" and value is not None: # If numfields is changed, reset min and max fields + self.minfields = value + self.maxfields = value + super(csvreader, self).__setattr__(name, value) + + def __iter__(self): + for row in self.reader: + self.line_num = self.reader.line_num - 1 - self._commentsbeforefirstline # Count is out due to reading first line in __init__ + if row == []: continue # Skip blank lines + if row[0].lstrip().startswith("#"): continue # Skip comments - ie lines starting with # + if len(row) < self.minfields or len(row) > self.maxfields: + self.logger.log("Invalid number of fields on line " + str(self.line_num) + " in "+self.filename, "E" ) + continue + yield row + + +def execute(tool, fn, scriptargspec, chain = None): + # Function to handle parameter parsing, font and file opening etc in command-line scripts + # Supports opening (and saving) fonts using PysilFont UFO (UFO), fontParts (FP) or fontTools (FT) + # Special handling for: + # -d variation on -h to print extra info about defaults + # -q quiet mode - only output a single line with count of errors (if there are any) + # -l opens log file and also creates a logger function to write to the log file + # -p other parameters. Includes backup settings and loglevel/scrlevel settings for logger + # for UFOlib scripts, also includes all outparams keys and ufometadata settings + + argspec = list(scriptargspec) + + chainfirst = False + if chain == "first": # If first call to execute has this set, only do the final return part of chaining + chainfirst = True + chain = None + + params = chain["params"] if chain else parameters() + logger = chain["logger"] if chain else params.logger # paramset has already created a basic logger + argv = chain["argv"] if chain else sys.argv + + if tool == "UFO": + from silfont.ufo import Ufont + elif tool == "FT": + from fontTools import ttLib + elif tool == "FP": + from fontParts.world import OpenFont + elif tool == "" or tool is None: + tool = None + else: + logger.log("Invalid tool in call to execute()", "X") + return + basemodule = sys.modules[fn.__module__] + poptions = {} + poptions['prog'] = splitfn(argv[0])[1] + poptions['description'] = basemodule.__doc__ + poptions['formatter_class'] = argparse.RawDescriptionHelpFormatter + epilog = "For more help options use -h ?. For more documentation see https://github.com/silnrsi/pysilfont/blob/master/docs/scripts.md#" + poptions['prog'] + "\n\n" + poptions['epilog'] = epilog + "Version: " + params.sets['default']['version'] + "\n" + params.sets['default']['copyright'] + + parser = argparse.ArgumentParser(**poptions) + parser._optionals.title = "other arguments" + + + # Add standard arguments + standardargs = { + 'quiet': ('-q', '--quiet', {'help': 'Quiet mode - only display severe errors', 'action': 'store_true'}, {}), + 'log': ('-l', '--log', {'help': 'Log file'}, {'type': 'outfile'}), + 'params': ('-p', '--params', {'help': 'Other parameters - see parameters.md for details', 'action': 'append'}, {'type': 'optiondict'}), + 'nq': ('--nq', {'help': argparse.SUPPRESS, 'action': 'store_true'}, {})} + + suppliedargs = [] + for a in argspec: + argn = a[:-2][-1] # [:-2] will give either 1 or 2, the last of which is the full argument name + if argn[0:2] == "--": argn = argn[2:] # Will start with -- for options + suppliedargs.append(argn) + for arg in sorted(standardargs): + if arg not in suppliedargs: argspec.append(standardargs[arg]) + + defhelp = False + if "-h" in argv: # Look for help option supplied + pos = argv.index("-h") + if pos < len(argv)-1: # There is something following -h! + opt = argv[pos+1] + if opt in ("d", "defaults"): + defhelp = True # Normal help will be displayed with default info displayed by the epilog + deffiles = [] + defother = [] + elif opt in ("p", "params"): + params.printhelp() + sys.exit(0) + else: + if opt != "?": + print("Invalid -h value") + print("-h ? displays help options") + print("-h d (or -h defaults) lists details of default values for arguments and parameters") + print("-h p (or -h params) gives help on parameters that can be set with -p or --params") + sys.exit(0) + + quiet = True if "-q" in argv and '--nq' not in argv else False + if quiet: logger.scrlevel = "S" + + # Process the supplied argument specs, add args to parser, store other info in arginfo + arginfo = [] + logdef = None + for a in argspec: + # Process all but last tuple entry as argparse arguments + nonkwds = a[:-2] + kwds = a[-2] + try: + parser.add_argument(*nonkwds, **kwds) + except Exception as e: + print(f'nonkwds: {nonkwds}, kwds: {kwds}') + print(e) + sys.exit(1) + + # Create ainfo, a dict of framework keywords using argument name + argn = nonkwds[-1] # Find the argument name from first 1 or 2 tuple entries + if argn[0:2] == "--": # Will start with -- for options + argn = argn[2:].replace("-", "_") # Strip the -- and replace any - in name with _ + ainfo=dict(a[-1]) #Make a copy so original argspec is not changed + for key in ainfo: # Check all keys are valid + if key not in ("def", "type", "optlog") : logger.log("Invalid argspec framework key: " + key, "X") + ainfo['name']=argn + if argn == 'log': + logdef = ainfo['def'] if 'def' in ainfo else None + optlog = ainfo['optlog'] if 'optlog' in ainfo else False + arginfo.append(ainfo) + if defhelp: + arg = nonkwds[0] + if 'def' in ainfo: + defval = ainfo['def'] + if argn == 'log' and logdef: defval += " in logs subdirectory" + deffiles.append([arg, defval]) + elif 'default' in kwds: + defother.append([arg, kwds['default']]) + + # if -h d specified, change the help epilog to info about argument defaults + if defhelp: + if not (deffiles or defother): + deftext = "No defaults for parameters/options" + else: + deftext = "Defaults for parameters/options - see user docs for details\n" + if deffiles: + deftext = deftext + "\n Font/file names\n" + for (param, defv) in deffiles: + deftext = deftext + ' {:<20}{}\n'.format(param, defv) + if defother: + deftext = deftext + "\n Other parameters\n" + for (param, defv) in defother: + deftext = deftext + ' {:<20}{}\n'.format(param, defv) + parser.epilog = deftext + "\n\n" + parser.epilog + + # Parse the command-line arguments. If errors or -h used, procedure will exit here + args = parser.parse_args(argv[1:]) + + # Process the first positional parameter to get defaults for file names + fppval = getattr(args, arginfo[0]['name']) + if isinstance(fppval, list): # When nargs="+" or nargs="*" is used a list is returned + (fppath, fpbase, fpext) = splitfn(fppval[0]) + if len(fppval) > 1 : fpbase = "wildcard" + else: + if fppval is None: fppval = "" # For scripts that can be run with no positional parameters + (fppath, fpbase, fpext) = splitfn(fppval) # First pos param use for defaulting + + # Process parameters + if chain: + execparams = params.sets["main"] + args.params = {} # clparams not used when chaining + else: + # Read config file from disk if it exists + configname = os.path.join(fppath, "pysilfont.cfg") + if os.path.exists(configname): + params.addset("config file", configname, configfile=configname) + else: + params.addset("config file") # Create empty set + if not quiet and "scrlevel" in params.sets["config file"]: logger.scrlevel = params.sets["config file"]["scrlevel"] + + # Process command-line parameters + clparams = {} + if 'params' in args.__dict__: + if args.params is not None: + for param in args.params: + x = param.split("=", 1) + if len(x) != 2: + logger.log("params must be of the form 'param=value'", "S") + if x[1] == "\\t": x[1] = "\t" # Special handling for tab characters + clparams[x[0]] = x[1] + + args.params = clparams + params.addset("command line", "command line", inputdict=clparams) + if not quiet and "scrlevel" in params.sets["command line"]: logger.scrlevel = params.sets["command line"]["scrlevel"] + + # Create main set of parameters based on defaults then update with config file values and command line values + params.addset("main", copyset="default") + params.sets["main"].updatewith("config file") + params.sets["main"].updatewith("command line") + execparams = params.sets["main"] + + # Set up logging + if chain: + setattr(args, 'logger', logger) + args.logfile = logger.logfile + else: + logfile = None + logname = args.log if 'log' in args.__dict__ and args.log is not None else "" + if 'log' in args.__dict__: + if logdef is not None and (logname != "" or optlog == False): + (path, base, ext) = splitfn(logname) + (dpath, dbase, dext) = splitfn(logdef) + if not path: + if base and ext: # If both specified then use cwd, ie no path + path = "" + else: + path = (fppath if dpath == "" else os.path.join(fppath, dpath)) + path = os.path.join(path, "logs") + if not base: + if dbase == "": + base = fpbase + elif dbase[0] == "_": # Append to font name if starts with _ + base = fpbase + dbase + else: + base = dbase + if not ext and dext: ext = dext + logname = os.path.join(path, base+ext) + if logname == "": + logfile = None + else: + (logname, logpath, exists) = fullpath(logname) + if not exists: + (parent,subd) = os.path.split(logpath) + if subd == "logs" and os.path.isdir(parent): # Create directory if just logs subdir missing + logger.log("Creating logs subdirectory in " + parent, "P") + os.makedirs(logpath, exist_ok=True) + else: # Fails, since missing dir is probably a typo! + logger.log("Directory " + parent + " does not exist", "S") + logger.log('Opening log file for output: ' + logname, "P") + try: + logfile = open(logname, "w", encoding="utf-8") + except Exception as e: + print(e) + sys.exit(1) + args.log = logfile + # Set up logger details + logger.loglevel = execparams['loglevel'].upper() + logger.logfile = logfile + if not quiet: logger.scrlevel = "E" # suppress next log message from screen + logger.log("Running: " + " ".join(argv), "P") + if not quiet: logger.scrlevel = execparams['scrlevel'].upper() + setattr(args, 'logger', logger) + +# Process the argument values returned from argparse + + outfont = None + infontlist = [] + for c, ainfo in enumerate(arginfo): + aval = getattr(args, ainfo['name']) + if ainfo['name'] in ('params', 'log'): continue # params and log already processed + atype = None + adef = None + if 'type' in ainfo: + atype = ainfo['type'] + if atype not in ('infont', 'outfont', 'infile', 'outfile', 'incsv', 'filename', 'optiondict'): + logger.log("Invalid type of " + atype + " supplied in argspec", "X") + if atype != 'optiondict': # All other types are file types, so adef must be set, even if just to "" + adef = ainfo['def'] if 'def' in ainfo else "" + if adef is None and aval is None: # If def explicitly set to None then this is optional + setattr(args, ainfo['name'], None) + continue + + if c == 0: + if aval is None : logger.log("Invalid first positional parameter spec", "X") + if aval[-1] in ("\\","/"): aval = aval[0:-1] # Remove trailing slashes + else: #Handle defaults for all but first positional parameter + if adef is not None: + if not aval: aval = "" +# if aval == "" and adef == "": # Only valid for output font parameter +# if atype != "outfont": +# logger.log("No value suppiled for " + ainfo['name'], "S") +# ## Not sure why this needs to fail - we need to cope with other optional file or filename parameters + (apath, abase, aext) = splitfn(aval) + (dpath, dbase, dext) = splitfn(adef) # dpath should be None + if not apath: + if abase and aext: # If both specified then use cwd, ie no path + apath = "" + else: + apath = fppath + if not abase: + if dbase == "": + abase = fpbase + elif dbase[0] == "_": # Append to font name if starts with _ + abase = fpbase + dbase + else: + abase = dbase + if not aext: + if dext: + aext = dext + elif (atype == 'outfont' or atype == 'infont'): aext = fpext + aval = os.path.join(apath, abase+aext) + + # Open files/fonts + if atype == 'infont': + if tool is None: + logger.log("Can't specify a font without a font tool", "X") + infontlist.append((ainfo['name'], aval)) # Build list of fonts to open when other args processed + elif atype == 'infile': + logger.log('Opening file for input: '+aval, "P") + try: + aval = open(aval, "r", encoding="utf-8") + except Exception as e: + print(e) + sys.exit(1) + elif atype == 'incsv': + logger.log('Opening file for input: '+aval, "P") + aval = csvreader(aval, logger=logger) + elif atype == 'outfile': + (aval, path, exists) = fullpath(aval) + if not exists: + logger.log("Output file directory " + path + " does not exist", "S") + logger.log('Opening file for output: ' + aval, "P") + try: + aval = open(aval, 'w', encoding="utf-8") + except Exception as e: + print(e) + sys.exit(1) + elif atype == 'outfont': + if tool is None: + logger.log("Can't specify a font without a font tool", "X") + outfont = aval + outfontpath = apath + outfontbase = abase + outfontext = aext + + elif atype == 'optiondict': # Turn multiple options in the form ['opt1=a', 'opt2=b'] into a dictionary + avaldict={} + if aval is not None: + for option in aval: + x = option.split("=", 1) + if len(x) != 2: + logger.log("options must be of the form 'param=value'", "S") + if x[1] == "\\t": x[1] = "\t" # Special handling for tab characters + avaldict[x[0]] = x[1] + aval = avaldict + + setattr(args, ainfo['name'], aval) + +# Open fonts - needs to be done after processing other arguments so logger and params are defined + + for name, aval in infontlist: + if chain and name == 'ifont': + aval = chain["font"] + else: + if tool == "UFO": aval = Ufont(aval, params=params) + if tool == "FT" : aval = ttLib.TTFont(aval) + if tool == "FP" : aval = OpenFont(aval) + setattr(args, name, aval) # Assign the font object to args attribute + +# All arguments processed, now call the main function + setattr(args, "paramsobj", params) + setattr(args, "cmdlineargs", argv) + newfont = fn(args) +# If an output font is expected and one is returned, output the font + if chainfirst: chain = True # Special handling for first call of chaining + if newfont: + if chain: # return font to be handled by chain() + return (args, newfont) + else: + if outfont: + # Backup the font if output is overwriting original input font + if outfont == infontlist[0][1]: + backupdir = os.path.join(outfontpath, execparams['backupdir']) + backupmax = int(execparams['backupkeep']) + backup = str2bool(execparams['backup']) + + if backup: + if not os.path.isdir(backupdir): # Create backup directory if not present + try: + os.mkdir(backupdir) + except Exception as e: + print(e) + sys.exit(1) + backupbase = os.path.join(backupdir, outfontbase+outfontext) + # Work out backup name based on existing backups + nums = sorted([int(i[len(backupbase)+1-len(i):-1]) for i in glob(backupbase+".*~")]) # Extract list of backup numbers from existing backups + newnum = max(nums)+1 if nums else 1 + backupname = backupbase+"."+str(newnum)+"~" + # Backup the font + logger.log("Backing up input font to "+backupname, "P") + shutil.copytree(outfont, backupname) + # Purge old backups + for i in range(0, len(nums) - backupmax + 1): + backupname = backupbase+"."+str(nums[i])+"~" + logger.log("Purging old backup "+backupname, "I") + shutil.rmtree(backupname) + else: + logger.log("No font backup done due to backup parameter setting", "I") + # Output the font + if tool in ("FT", "FP"): + logger.log("Saving font to " + outfont, "P") + newfont.save(outfont) + else: # Must be Pyslifont Ufont + newfont.write(outfont) + else: + logger.log("Font returned to execute() but no output font is specified in arg spec", "X") + elif chain: # ) When chaining return just args - the font can be accessed by args.ifont + return (args, None) # ) assuming that the script has not changed the input font + + if logger.errorcount or logger.warningcount: + message = "Command completed with " + str(logger.errorcount) + " errors and " + str(logger.warningcount) + " warnings" + if logger.scrlevel in ("S", "E") and logname != "": + if logger.scrlevel == "S" or logger.warningcount: message = message + " - see " + logname + if logger.errorcount: + if quiet: logger.raisescrlevel("E") + logger.log(message, "E") + logger.resetscrlevel() + else: + logger.log(message, "P") + if logger.scrlevel == "P" and logger.warningcount: logger.log("See log file for warning messages or rerun with '-p scrlevel=w'", "P") + else: + logger.log("Command completed with no warnings", "P") + + return (args, newfont) + + +def chain(argv, function, argspec, font, params, logger, quiet): # Chain multiple command-line scripts using UFO module together without writing font to disk + ''' argv is a command-line call to a script in sys.argv format. function and argspec are from the script being called. + Although input font name must be supplied for the command line to be parsed correctly by execute() it is not used - instead the supplied + font object is used. Similarly -params, logfile and quiet settings in argv are not used by execute() when chaining is used''' + if quiet and "-q" not in argv: argv.append("-q") + logger.log("Chaining to " + argv[0], "P") + font = execute("UFO", function, argspec, + {'argv' : argv, + 'font' : font, + 'params': params, + 'logger': logger, + 'quiet' : quiet}) + logger.log("Returning from " + argv[0], "P") + return font + + +def splitfn(fn): # Split filename into path, base and extension + if fn: # Remove trailing slashes + if fn[-1] in ("\\","/"): fn = fn[0:-1] + (path, base) = os.path.split(fn) + (base, ext) = os.path.splitext(base) + # Handle special case where just a directory is supplied + if ext == "": # If there's an extension, treat as file name, eg a ufo directory + if os.path.isdir(fn): + path = fn + base = "" + return (path, base, ext) + + +def str2bool(v): # If v is not a boolean, convert from string to boolean + if type(v) == bool: return v + v = v.lower() + if v in ("yes", "y", "true", "t", "1"): + v = True + elif v in ("no", "n", "false", "f", "0"): + v = False + else: + v = None + return v + +def fullpath(filen): # Changes file name to one with full path and checks directory exists + fullname = os.path.abspath(filen) + (fpath,dummy) = os.path.split(fullname) + return fullname, fpath, os.path.isdir(fpath) diff --git a/src/silfont/data/required_chars.csv b/src/silfont/data/required_chars.csv new file mode 100644 index 0000000..939c371 --- /dev/null +++ b/src/silfont/data/required_chars.csv @@ -0,0 +1,308 @@ +USV,ps_name,glyph_name,sil_set,rationale,additional_notes +U+0020,space,space,basic,A, +U+0021,exclam,exclam,basic,A, +U+0022,quotedbl,quotedbl,basic,A, +U+0023,numbersign,numbersign,basic,A, +U+0024,dollar,dollar,basic,A, +U+0025,percent,percent,basic,A, +U+0026,ampersand,ampersand,basic,A, +U+0027,quotesingle,quotesingle,basic,A, +U+0028,parenleft,parenleft,basic,A, +U+0029,parenright,parenright,basic,A, +U+002A,asterisk,asterisk,basic,A, +U+002B,plus,plus,basic,A, +U+002C,comma,comma,basic,A, +U+002D,hyphen,hyphen,basic,A, +U+002E,period,period,basic,A, +U+002F,slash,slash,basic,A, +U+0030,zero,zero,basic,A, +U+0031,one,one,basic,A, +U+0032,two,two,basic,A, +U+0033,three,three,basic,A, +U+0034,four,four,basic,A, +U+0035,five,five,basic,A, +U+0036,six,six,basic,A, +U+0037,seven,seven,basic,A, +U+0038,eight,eight,basic,A, +U+0039,nine,nine,basic,A, +U+003A,colon,colon,basic,A, +U+003B,semicolon,semicolon,basic,A, +U+003C,less,less,basic,A, +U+003D,equal,equal,basic,A, +U+003E,greater,greater,basic,A, +U+003F,question,question,basic,A, +U+0040,at,at,basic,A, +U+0041,A,A,basic,A, +U+0042,B,B,basic,A, +U+0043,C,C,basic,A, +U+0044,D,D,basic,A, +U+0045,E,E,basic,A, +U+0046,F,F,basic,A, +U+0047,G,G,basic,A, +U+0048,H,H,basic,A, +U+0049,I,I,basic,A, +U+004A,J,J,basic,A, +U+004B,K,K,basic,A, +U+004C,L,L,basic,A, +U+004D,M,M,basic,A, +U+004E,N,N,basic,A, +U+004F,O,O,basic,A, +U+0050,P,P,basic,A, +U+0051,Q,Q,basic,A, +U+0052,R,R,basic,A, +U+0053,S,S,basic,A, +U+0054,T,T,basic,A, +U+0055,U,U,basic,A, +U+0056,V,V,basic,A, +U+0057,W,W,basic,A, +U+0058,X,X,basic,A, +U+0059,Y,Y,basic,A, +U+005A,Z,Z,basic,A, +U+005B,bracketleft,bracketleft,basic,A, +U+005C,backslash,backslash,basic,A, +U+005D,bracketright,bracketright,basic,A, +U+005E,asciicircum,asciicircum,basic,A, +U+005F,underscore,underscore,basic,A, +U+0060,grave,grave,basic,A, +U+0061,a,a,basic,A, +U+0062,b,b,basic,A, +U+0063,c,c,basic,A, +U+0064,d,d,basic,A, +U+0065,e,e,basic,A, +U+0066,f,f,basic,A, +U+0067,g,g,basic,A, +U+0068,h,h,basic,A, +U+0069,i,i,basic,A, +U+006A,j,j,basic,A, +U+006B,k,k,basic,A, +U+006C,l,l,basic,A, +U+006D,m,m,basic,A, +U+006E,n,n,basic,A, +U+006F,o,o,basic,A, +U+0070,p,p,basic,A, +U+0071,q,q,basic,A, +U+0072,r,r,basic,A, +U+0073,s,s,basic,A, +U+0074,t,t,basic,A, +U+0075,u,u,basic,A, +U+0076,v,v,basic,A, +U+0077,w,w,basic,A, +U+0078,x,x,basic,A, +U+0079,y,y,basic,A, +U+007A,z,z,basic,A, +U+007B,braceleft,braceleft,basic,A, +U+007C,bar,bar,basic,A, +U+007D,braceright,braceright,basic,A, +U+007E,asciitilde,asciitilde,basic,A, +U+00A0,uni00A0,nbspace,basic,A, +U+00A1,exclamdown,exclamdown,basic,A, +U+00A2,cent,cent,basic,A, +U+00A3,sterling,sterling,basic,A, +U+00A4,currency,currency,basic,A, +U+00A5,yen,yen,basic,A, +U+00A6,brokenbar,brokenbar,basic,A, +U+00A7,section,section,basic,A, +U+00A8,dieresis,dieresis,basic,A, +U+00A9,copyright,copyright,basic,A, +U+00AA,ordfeminine,ordfeminine,basic,A, +U+00AB,guillemotleft,guillemetleft,basic,A, +U+00AC,logicalnot,logicalnot,basic,A, +U+00AD,uni00AD,softhyphen,basic,A, +U+00AE,registered,registered,basic,A, +U+00AF,macron,macron,basic,A, +U+00B0,degree,degree,basic,A, +U+00B1,plusminus,plusminus,basic,A, +U+00B2,uni00B2,twosuperior,basic,A, +U+00B3,uni00B3,threesuperior,basic,A, +U+00B4,acute,acute,basic,A, +U+00B5,mu,micro,basic,A, +U+00B6,paragraph,paragraph,basic,A, +U+00B7,periodcentered,periodcentered,basic,A, +U+00B8,cedilla,cedilla,basic,A, +U+00B9,uni00B9,onesuperior,basic,A, +U+00BA,ordmasculine,ordmasculine,basic,A, +U+00BB,guillemotright,guillemetright,basic,A, +U+00BC,onequarter,onequarter,basic,A, +U+00BD,onehalf,onehalf,basic,A, +U+00BE,threequarters,threequarters,basic,A, +U+00BF,questiondown,questiondown,basic,A, +U+00C0,Agrave,Agrave,basic,A, +U+00C1,Aacute,Aacute,basic,A, +U+00C2,Acircumflex,Acircumflex,basic,A, +U+00C3,Atilde,Atilde,basic,A, +U+00C4,Adieresis,Adieresis,basic,A, +U+00C5,Aring,Aring,basic,A, +U+00C6,AE,AE,basic,A, +U+00C7,Ccedilla,Ccedilla,basic,A, +U+00C8,Egrave,Egrave,basic,A, +U+00C9,Eacute,Eacute,basic,A, +U+00CA,Ecircumflex,Ecircumflex,basic,A, +U+00CB,Edieresis,Edieresis,basic,A, +U+00CC,Igrave,Igrave,basic,A, +U+00CD,Iacute,Iacute,basic,A, +U+00CE,Icircumflex,Icircumflex,basic,A, +U+00CF,Idieresis,Idieresis,basic,A, +U+00D0,Eth,Eth,basic,A, +U+00D1,Ntilde,Ntilde,basic,A, +U+00D2,Ograve,Ograve,basic,A, +U+00D3,Oacute,Oacute,basic,A, +U+00D4,Ocircumflex,Ocircumflex,basic,A, +U+00D5,Otilde,Otilde,basic,A, +U+00D6,Odieresis,Odieresis,basic,A, +U+00D7,multiply,multiply,basic,A, +U+00D8,Oslash,Oslash,basic,A, +U+00D9,Ugrave,Ugrave,basic,A, +U+00DA,Uacute,Uacute,basic,A, +U+00DB,Ucircumflex,Ucircumflex,basic,A, +U+00DC,Udieresis,Udieresis,basic,A, +U+00DD,Yacute,Yacute,basic,A, +U+00DE,Thorn,Thorn,basic,A, +U+00DF,germandbls,germandbls,basic,A, +U+00E0,agrave,agrave,basic,A, +U+00E1,aacute,aacute,basic,A, +U+00E2,acircumflex,acircumflex,basic,A, +U+00E3,atilde,atilde,basic,A, +U+00E4,adieresis,adieresis,basic,A, +U+00E5,aring,aring,basic,A, +U+00E6,ae,ae,basic,A, +U+00E7,ccedilla,ccedilla,basic,A, +U+00E8,egrave,egrave,basic,A, +U+00E9,eacute,eacute,basic,A, +U+00EA,ecircumflex,ecircumflex,basic,A, +U+00EB,edieresis,edieresis,basic,A, +U+00EC,igrave,igrave,basic,A, +U+00ED,iacute,iacute,basic,A, +U+00EE,icircumflex,icircumflex,basic,A, +U+00EF,idieresis,idieresis,basic,A, +U+00F0,eth,eth,basic,A, +U+00F1,ntilde,ntilde,basic,A, +U+00F2,ograve,ograve,basic,A, +U+00F3,oacute,oacute,basic,A, +U+00F4,ocircumflex,ocircumflex,basic,A, +U+00F5,otilde,otilde,basic,A, +U+00F6,odieresis,odieresis,basic,A, +U+00F7,divide,divide,basic,A, +U+00F8,oslash,oslash,basic,A, +U+00F9,ugrave,ugrave,basic,A, +U+00FA,uacute,uacute,basic,A, +U+00FB,ucircumflex,ucircumflex,basic,A, +U+00FC,udieresis,udieresis,basic,A, +U+00FD,yacute,yacute,basic,A, +U+00FE,thorn,thorn,basic,A, +U+00FF,ydieresis,ydieresis,basic,A, +U+0131,dotlessi,idotless,basic,B, +U+0152,OE,OE,basic,A, +U+0153,oe,oe,basic,A, +U+0160,Scaron,Scaron,basic,A, +U+0161,scaron,scaron,basic,A, +U+0178,Ydieresis,Ydieresis,basic,A, +U+017D,Zcaron,Zcaron,basic,A, +U+017E,zcaron,zcaron,basic,A, +U+0192,florin,florin,basic,A, +U+02C6,circumflex,circumflex,basic,A, +U+02C7,caron,caron,basic,B, +U+02D8,breve,breve,basic,B, +U+02D9,dotaccent,dotaccent,basic,B, +U+02DA,ring,ring,basic,B, +U+02DB,ogonek,ogonek,basic,B, +U+02DC,tilde,tilde,basic,A, +U+02DD,hungarumlaut,hungarumlaut,basic,B, +U+034F,uni034F,graphemejoinercomb,basic,D, +U+03C0,pi,pi,basic,B, +U+2000,uni2000,enquad,basic,C, +U+2001,uni2001,emquad,basic,C, +U+2002,uni2002,enspace,basic,C, +U+2003,uni2003,emspace,basic,C, +U+2004,uni2004,threeperemspace,basic,C, +U+2005,uni2005,fourperemspace,basic,C, +U+2006,uni2006,sixperemspace,basic,C, +U+2007,uni2007,figurespace,basic,C, +U+2008,uni2008,punctuationspace,basic,C, +U+2009,uni2009,thinspace,basic,C, +U+200A,uni200A,hairspace,basic,C, +U+200B,uni200B,zerowidthspace,basic,C, +U+200C,uni200C,zerowidthnonjoiner,basic,D, +U+200D,uni200D,zerowidthjoiner,basic,D, +U+200E,uni200E,lefttorightmark,rtl,D, +U+200F,uni200F,righttoleftmark,rtl,D, +U+2010,uni2010,hyphentwo,basic,C, +U+2011,uni2011,nonbreakinghyphen,basic,C, +U+2012,figuredash,figuredash,basic,C, +U+2013,endash,endash,basic,A, +U+2014,emdash,emdash,basic,A, +U+2015,uni2015,horizontalbar,basic,C, +U+2018,quoteleft,quoteleft,basic,A, +U+2019,quoteright,quoteright,basic,A, +U+201A,quotesinglbase,quotesinglbase,basic,A, +U+201C,quotedblleft,quotedblleft,basic,A, +U+201D,quotedblright,quotedblright,basic,A, +U+201E,quotedblbase,quotedblbase,basic,A, +U+2020,dagger,dagger,basic,A, +U+2021,daggerdbl,daggerdbl,basic,A, +U+2022,bullet,bullet,basic,A, +U+2026,ellipsis,ellipsis,basic,A, +U+2027,uni2027,hyphenationpoint,basic,C, +U+2028,uni2028,lineseparator,basic,C, +U+2029,uni2029,paragraphseparator,basic,C, +U+202A,uni202A,lefttorightembedding,rtl,D, +U+202B,uni202B,righttoleftembedding,rtl,D, +U+202C,uni202C,popdirectionalformatting,rtl,D, +U+202D,uni202D,lefttorightoverride,rtl,D, +U+202E,uni202E,righttoleftoverride,rtl,D, +U+202F,uni202F,narrownbspace,basic,C, +U+2030,perthousand,perthousand,basic,A, +U+2039,guilsinglleft,guilsinglleft,basic,A, +U+203A,guilsinglright,guilsinglright,basic,A, +U+2044,fraction,fraction,basic,B, +U+2060,uni2060,wordjoiner,basic,D, +U+2066,uni2066,lefttorightisolate,rtl,D, +U+2067,uni2067,righttoleftisolate,rtl,D, +U+2068,uni2068,firststrongisolate,rtl,D, +U+2069,uni2069,popdirectionalisolate,rtl,D, +U+206C,uni206C,inhibitformshaping-ar,rtl,D, +U+206D,uni206D,activateformshaping-ar,rtl,D, +U+2074,uni2074,foursuperior,basic,E, +U+20AC,Euro,euro,basic,A, +U+2122,trademark,trademark,basic,A, +U+2126,Omega,Ohm,basic,B, +U+2202,partialdiff,partialdiff,basic,B, +U+2206,Delta,Delta,basic,B, +U+220F,product,product,basic,B, +U+2211,summation,summation,basic,B, +U+2212,minus,minus,basic,E, +U+2215,uni2215,divisionslash,basic,E, +U+2219,uni2219,bulletoperator,basic,C,Some applications use this instead of 00B7 +U+221A,radical,radical,basic,B, +U+221E,infinity,infinity,basic,B, +U+222B,integral,integral,basic,B, +U+2248,approxequal,approxequal,basic,B, +U+2260,notequal,notequal,basic,B, +U+2264,lessequal,lessequal,basic,B, +U+2265,greaterequal,greaterequal,basic,B, +U+2423,uni2423,blank,basic,F,Advanced width should probably be the same as a space. +U+25CA,lozenge,lozenge,basic,B, +U+25CC,uni25CC,dottedCircle,basic,J,"If your OpenType font supports combining diacritics, be sure to include U+25CC DOTTED CIRCLE in your font, and optionally include this in your positioning rules for all your combining marks. This is because Uniscribe will insert U+25CC between ""illegal"" diacritic sequences (such as two U+064E characters in a row) to make the mistake more visible. (https://docs.microsoft.com/en-us/typography/script-development/arabic#handling-invalid-combining-marks)" +U+F130,uniF130,FontBslnSideBrngMrkrLft,sil,K, +U+F131,uniF131,FontBslnSideBrngMrkrRt,sil,K, +U+FB01,uniFB01,fi,basic,B, +U+FB02,uniFB02,fl,basic,B, +U+FE00,uniFE00,VS1,basic,H,Add this to the cmap and point them to null glyphs +U+FE01,uniFE01,VS2,basic,H,Add this to the cmap and point them to null glyphs +U+FE02,uniFE02,VS3,basic,H,Add this to the cmap and point them to null glyphs +U+FE03,uniFE03,VS4,basic,H,Add this to the cmap and point them to null glyphs +U+FE04,uniFE04,VS5,basic,H,Add this to the cmap and point them to null glyphs +U+FE05,uniFE05,VS6,basic,H,Add this to the cmap and point them to null glyphs +U+FE06,uniFE06,VS7,basic,H,Add this to the cmap and point them to null glyphs +U+FE07,uniFE07,VS8,basic,H,Add this to the cmap and point them to null glyphs +U+FE08,uniFE08,VS9,basic,H,Add this to the cmap and point them to null glyphs +U+FE09,uniFE09,VS10,basic,H,Add this to the cmap and point them to null glyphs +U+FE0A,uniFE0A,VS11,basic,H,Add this to the cmap and point them to null glyphs +U+FE0B,uniFE0B,VS12,basic,H,Add this to the cmap and point them to null glyphs +U+FE0C,uniFE0C,VS13,basic,H,Add this to the cmap and point them to null glyphs +U+FE0D,uniFE0D,VS14,basic,H,Add this to the cmap and point them to null glyphs +U+FE0E,uniFE0E,VS15,basic,H,Add this to the cmap and point them to null glyphs +U+FE0F,uniFE0F,VS16,basic,H,Add this to the cmap and point them to null glyphs +U+FEFF,uniFEFF,zeroWidthNoBreakSpace,basic,I,Making this visible might be helpful +U+FFFC,uniFFFC,objectReplacementCharacter,basic,G,It is easier for someone looking at the converted text to figure out what's going on if these have a visual representation. +U+FFFD,uniFFFD,replacementCharacter,basic,G,It is easier for someone looking at the converted text to figure out what's going on if these have a visual representation. +,,,,, diff --git a/src/silfont/data/required_chars.md b/src/silfont/data/required_chars.md new file mode 100644 index 0000000..444f2e6 --- /dev/null +++ b/src/silfont/data/required_chars.md @@ -0,0 +1,32 @@ +# required_chars - recommended characters for Non-Roman fonts + +For optimal compatibility with a variety of operating systems, all Non-Roman fonts should include +a set of glyphs for basic Roman characters and punctuation. Ideally this should include all the +following characters, although some depend on other considerations (see the notes). The basis +for this list is a union of the Windows Codepage 1252 and MacRoman character sets plus additional +useful characters. + +The csv includes the following headers: + +* USV - Unicode Scalar Value +* ps_name - postscript name of glyph that will end up in production +* glyph_name - glyphsApp name that will be used in UFO +* sil_set - set to include in a font + * basic - should be included in any Non-Roman font + * rtl - should be included in any right-to-left script font + * sil - should be included in any SIL font +* rationale - worded to complete the phrase: "This character is needed ..." + * A - in Codepage 1252 + * B - in MacRoman + * C - for publishing + * D - for Non-Roman fonts and publishing + * E - by Google Fonts + * F - by TeX for visible space + * G - for encoding conversion utilities + * H - in case Variation Sequences are defined in future + * I - to detect byte order + * J - to render combining marks in isolation + * K - to view sidebearings for every glyph using these characters +* additional_notes - how the character might be used + +The list was previously maintained here: https://scriptsource.org/entry/gg5wm9hhd3 diff --git a/src/silfont/etutil.py b/src/silfont/etutil.py new file mode 100644 index 0000000..35e5a0a --- /dev/null +++ b/src/silfont/etutil.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +'Classes and functions for handling XML files in pysilfont scripts' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from xml.etree import ElementTree as ET +import silfont.core + +import re, os, codecs, io, collections + +_elementprotect = { + '&' : '&', + '<' : '<', + '>' : '>' } +_attribprotect = dict(_elementprotect) +_attribprotect['"'] = '"' # Copy of element protect with double quote added + +class ETWriter(object) : + """ General purpose ElementTree pretty printer complete with options for attribute order + beyond simple sorting, and which elements should use cdata + + Note there is no support for namespaces. Originally there was, and if it is needed in the future look at + commits from 10th May 2018 or earlier. The code there would need reworking!""" + + def __init__(self, etree, attributeOrder = {}, takesCData = set(), + indentIncr = " ", indentFirst = " ", indentML = False, inlineelem=[], precision = None, floatAttribs = [], intAttribs = []): + self.root = etree + self.attributeOrder = attributeOrder # Sort order for attributes - just one list for all elements + self.takesCData = takesCData + self.indentIncr = indentIncr # Incremental increase in indent + self.indentFirst = indentFirst # Indent for first level + self.indentML = indentML # Add indent to multi-line strings + self.inlineelem = inlineelem # For supporting in-line elements. Does not work with mix of inline and other subelements in same element + self.precision = precision # Precision to use outputting numeric attribute values + self.floatAttribs = floatAttribs # List of float/real attributes used with precision + self.intAttribs = intAttribs + + def _protect(self, txt, base=_attribprotect) : + return re.sub(r'['+r"".join(base.keys())+r"]", lambda m: base[m.group(0)], txt) + + def serialize_xml(self, base = None, indent = '') : + # Create the xml and return as a string + outstrings = [] + outstr="" + if base is None : + base = self.root + outstr += '<?xml version="1.0" encoding="UTF-8"?>\n' + if '.pi' in base.attrib : # Processing instructions + for pi in base.attrib['.pi'].split(",") : outstr += '<?{}?>\n'.format(pi) + + if '.doctype' in base.attrib : outstr += '<!DOCTYPE {}>\n'.format(base.attrib['.doctype']) + + tag = base.tag + attribs = base.attrib + + if '.comments' in attribs : + for c in attribs['.comments'].split(",") : outstr += '{}<!--{}-->\n'.format(indent, c) + + i = indent if tag not in self.inlineelem else "" + outstr += '{}<{}'.format(i, tag) + + for k in sorted(list(attribs.keys()), key=lambda x: self.attributeOrder.get(x, x)): + if k[0] != '.' : + att = attribs[k] + if self.precision is not None and k in self.floatAttribs : + if "." in att: + num = round(float(att), self.precision) + att = int(num) if num == int(num) else num + elif k in self.intAttribs : + att = int(round(float(att))) + else: + att = self._protect(att) + outstr += ' {}="{}"'.format(k, att) + + if len(base) or (base.text and base.text.strip()) : + outstr += '>' + if base.text and base.text.strip() : + if tag not in self.takesCData : + t = base.text + if self.indentML : t = t.replace('\n', '\n' + indent) + t = self._protect(t, base=_elementprotect) + else : + t = "<![CDATA[\n\t" + indent + base.text.replace('\n', '\n\t' + indent) + "\n" + indent + "]]>" + outstr += t + if len(base) : + if base[0].tag not in self.inlineelem : outstr += '\n' + if base == self.root: + incr = self.indentFirst + else: + incr = self.indentIncr + outstrings.append(outstr); outstr="" + for b in base : outstrings.append(self.serialize_xml(base=b, indent=indent + incr)) + if base[-1].tag not in self.inlineelem : outstr += indent + outstr += '</{}>'.format(tag) + else : + outstr += '/>' + if base.tail and base.tail.strip() : + outstr += self._protect(base.tail, base=_elementprotect) + if tag not in self.inlineelem : outstr += "\n" + + if '.commentsafter' in base.attrib : + for c in base.attrib['.commentsafter'].split(",") : outstr += '{}<!--{}-->\n'.format(indent, c) + + outstrings.append(outstr) + return "".join(outstrings) + +class _container(object) : + # Parent class for other objects + def __init_(self) : + self._contents = {} + # Define methods so it acts like an imutable container + # (changes should be made via object functions etc) + def __len__(self): + return len(self._contents) + def __getitem__(self, key): + return self._contents[key] + def __iter__(self): + return iter(self._contents) + def keys(self) : + return self._contents.keys() + +class xmlitem(_container): + """ The xml data item for an xml file""" + + def __init__(self, dirn = None, filen = None, parse = True, logger=None) : + self.logger = logger if logger else silfont.core.loggerobj() + self._contents = {} + self.dirn = dirn + self.filen = filen + self.inxmlstr = "" + self.outxmlstr = "" + self.etree = None + self.type = None + if filen and dirn : + fulln = os.path.join( dirn, filen) + self.inxmlstr = io.open(fulln, "rt", encoding="utf-8").read() + if parse : + try: + self.etree = ET.fromstring(self.inxmlstr) + except: + try: + self.etree = ET.fromstring(self.inxmlstr.encode("utf-8")) + except Exception as e: + self.logger.log("Failed to parse xml for " + fulln, "E") + self.logger.log(str(e), "S") + + def write_to_file(self,dirn,filen) : + outfile = io.open(os.path.join(dirn,filen),'w', encoding="utf-8") + outfile.write(self.outxmlstr) + +class ETelement(_container): + # Class for an etree element. Mainly used as a parent class + # For each tag in the element, ETelement[tag] returns a list of sub-elements with that tag + # process_subelements can set attributes for each tag based on a supplied spec + def __init__(self,element) : + self.element = element + self._contents = {} + self.reindex() + + def reindex(self) : + self._contents = collections.defaultdict(list) + for e in self.element : + self._contents[e.tag].append(e) + + def remove(self,subelement) : + self._contents[subelement.tag].remove(subelement) + self.element.remove(subelement) + + def append(self,subelement) : + self._contents[subelement.tag].append(subelement) + self.element.append(subelement) + + def insert(self,index,subelement) : + self._contents[subelement.tag].insert(index,subelement) + self.element.insert(index,subelement) + + def replace(self,index,subelement) : + self._contents[subelement.tag][index] = subelement + self.element[index] = subelement + + def process_attributes(self, attrspec, others = False) : + # Process attributes based on list of attributes in the format: + # (element attr name, object attr name, required) + # If attr does not exist and is not required, set to None + # If others is True, attributes not in the list are allowed + # Attributes should be listed in the order they should be output if writing xml out + + if not hasattr(self,"parseerrors") or self.parseerrors is None: self.parseerrors=[] + + speclist = {} + for (i,spec) in enumerate(attrspec) : speclist[spec[0]] = attrspec[i] + + for eaname in speclist : + (eaname,oaname,req) = speclist[eaname] + setattr(self, oaname, getattrib(self.element,eaname)) + if req and getattr(self, oaname) is None : self.parseerrors.append("Required attribute " + eaname + " missing") + + # check for any other attributes + for att in self.element.attrib : + if att not in speclist : + if others: + setattr(self, att, getattrib(self.element,att)) + else : + self.parseerrors.append("Invalid attribute " + att) + + def process_subelements(self,subspec, offspec = False) : + # Process all subelements based on spec of expected elements + # subspec is a list of elements, with each list in the format: + # (element name, attribute name, class name, required, multiple valeus allowed) + # If cl is set, attribute is set to an object made with that class; otherwise just text of the element + + if not hasattr(self,"parseerrors") or self.parseerrors is None : self.parseerrors=[] + + def make_obj(self,cl,element) : # Create object from element and cascade parse errors down + if cl is None : return element.text + if cl is ETelement : + obj = cl(element) # ETelement does not require parent object, ie self + else : + obj = cl(self,element) + if hasattr(obj,"parseerrors") and obj.parseerrors != [] : + if hasattr(obj,"name") and obj.name is not None : # Try to find a name for error reporting + name = obj.name + elif hasattr(obj,"label") and obj.label is not None : + name = obj.label + else : + name = "" + + self.parseerrors.append("Errors parsing " + element.tag + " element: " + name) + for error in obj.parseerrors : + self.parseerrors.append(" " + error) + return obj + + speclist = {} + for (i,spec) in enumerate(subspec) : speclist[spec[0]] = subspec[i] + + for ename in speclist : + (ename,aname,cl,req,multi) = speclist[ename] + initval = [] if multi else None + setattr(self,aname,initval) + + for ename in self : # Process all elements + if ename in speclist : + (ename,aname,cl,req,multi) = speclist[ename] + elements = self[ename] + if multi : + for elem in elements : getattr(self,aname).append(make_obj(self,cl,elem)) + else : + setattr(self,aname,make_obj(self,cl,elements[0])) + if len(elements) > 1 : self.parseerrors.append("Multiple " + ename + " elements not allowed") + else: + if offspec: # Elements not in spec are allowed so create list of sub-elemente. + setattr(self,ename,[]) + for elem in elements : getattr(self,ename).append(ETelement(elem)) + else : + self.parseerrors.append("Invalid element: " + ename) + + for ename in speclist : # Check values exist for required elements etc + (ename,aname,cl,req,multi) = speclist[ename] + + val = getattr(self,aname) + if req : + if multi and val == [] : self.parseerrors.append("No " + ename + " elements ") + if not multi and val == None : self.parseerrors.append("No " + ename + " element") + +def makeAttribOrder(attriblist) : # Turn a list of attrib names into an attributeOrder dict for ETWriter + return dict(map(lambda x:(x[1], x[0]), enumerate(attriblist))) + +def getattrib(element,attrib) : return element.attrib[attrib] if attrib in element.attrib else None diff --git a/src/silfont/fbtests/__init__.py b/src/silfont/fbtests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/silfont/fbtests/__init__.py diff --git a/src/silfont/fbtests/silnotcjk.py b/src/silfont/fbtests/silnotcjk.py new file mode 100644 index 0000000..742c6a2 --- /dev/null +++ b/src/silfont/fbtests/silnotcjk.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +'''These are copies of checks that have the "not is_cjk" condition, but these versions have that condition removed. +The is_cjk condition was being matched by multiple fonts that are not cjk fonts - but do have some cjk punctuation characters. +These checks based on based on examples from Font Bakery, copyright 2017 The Font Bakery Authors, licensed under the Apache 2.0 license''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2022 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from fontbakery.status import PASS, FAIL, WARN, ERROR, INFO, SKIP +from fontbakery.callable import condition, check, disable +from fontbakery.message import Message +from fontbakery.profiles.shared_conditions import typo_metrics_enabled +import os +from fontbakery.constants import NameID, PlatformID, WindowsEncodingID + +@check( + id = 'org.sil/check/family/win_ascent_and_descent', + conditions = ['vmetrics'], + rationale = """ + Based on com.google.fonts/check/family/win_ascent_and_descent but with the 'not is_cjk' condition removed + """ +) +def org_sil_check_family_win_ascent_and_descent(ttFont, vmetrics): + """Checking OS/2 usWinAscent & usWinDescent.""" + + if "OS/2" not in ttFont: + yield FAIL,\ + Message("lacks-OS/2", + "Font file lacks OS/2 table") + return + + failed = False + os2_table = ttFont['OS/2'] + win_ascent = os2_table.usWinAscent + win_descent = os2_table.usWinDescent + y_max = vmetrics['ymax'] + y_min = vmetrics['ymin'] + + # OS/2 usWinAscent: + if win_ascent < y_max: + failed = True + yield FAIL,\ + Message("ascent", + f"OS/2.usWinAscent value should be" + f" equal or greater than {y_max}," + f" but got {win_ascent} instead") + if win_ascent > y_max * 2: + failed = True + yield FAIL,\ + Message("ascent", + f"OS/2.usWinAscent value" + f" {win_ascent} is too large." + f" It should be less than double the yMax." + f" Current yMax value is {y_max}") + # OS/2 usWinDescent: + if win_descent < abs(y_min): + failed = True + yield FAIL,\ + Message("descent", + f"OS/2.usWinDescent value should be equal or" + f" greater than {abs(y_min)}, but got" + f" {win_descent} instead.") + + if win_descent > abs(y_min) * 2: + failed = True + yield FAIL,\ + Message("descent", + f"OS/2.usWinDescent value" + f" {win_descent} is too large." + f" It should be less than double the yMin." + f" Current absolute yMin value is {abs(y_min)}") + if not failed: + yield PASS, "OS/2 usWinAscent & usWinDescent values look good!" + + +@check( + id = 'org.sil/check/os2_metrics_match_hhea', + rationale=""" + Based on com.google.fonts/check/os2_metrics_match_hhea but with the 'not is_cjk' condition removed + """ +) +def org_sil_check_os2_metrics_match_hhea(ttFont): + """Checking OS/2 Metrics match hhea Metrics.""" + + filename = os.path.basename(ttFont.reader.file.name) + + # Check both OS/2 and hhea are present. + missing_tables = False + + required = ["OS/2", "hhea"] + for key in required: + if key not in ttFont: + missing_tables = True + yield FAIL,\ + Message(f'lacks-{key}', + f"{filename} lacks a '{key}' table.") + + if missing_tables: + return + + # OS/2 sTypoAscender and sTypoDescender match hhea ascent and descent + if ttFont["OS/2"].sTypoAscender != ttFont["hhea"].ascent: + yield FAIL,\ + Message("ascender", + f"OS/2 sTypoAscender ({ttFont['OS/2'].sTypoAscender})" + f" and hhea ascent ({ttFont['hhea'].ascent})" + f" must be equal.") + elif ttFont["OS/2"].sTypoDescender != ttFont["hhea"].descent: + yield FAIL,\ + Message("descender", + f"OS/2 sTypoDescender ({ttFont['OS/2'].sTypoDescender})" + f" and hhea descent ({ttFont['hhea'].descent})" + f" must be equal.") + elif ttFont["OS/2"].sTypoLineGap != ttFont["hhea"].lineGap: + yield FAIL,\ + Message("lineGap", + f"OS/2 sTypoLineGap ({ttFont['OS/2'].sTypoLineGap})" + f" and hhea lineGap ({ttFont['hhea'].lineGap})" + f" must be equal.") + else: + yield PASS, ("OS/2.sTypoAscender/Descender values" + " match hhea.ascent/descent.") + +@check( + id = "org.sil/check/os2/use_typo_metrics", + rationale=""" + Based on com.google.fonts/check/os2/use_typo_metrics but with the 'not is_cjk' condition removed + """ + ) +def corg_sil_check_os2_fsselectionbit7(ttFonts): + """OS/2.fsSelection bit 7 (USE_TYPO_METRICS) is set in all fonts.""" + + bad_fonts = [] + for ttFont in ttFonts: + if not ttFont["OS/2"].fsSelection & (1 << 7): + bad_fonts.append(ttFont.reader.file.name) + + if bad_fonts: + yield FAIL,\ + Message('missing-os2-fsselection-bit7', + f"OS/2.fsSelection bit 7 (USE_TYPO_METRICS) was" + f"NOT set in the following fonts: {bad_fonts}.") + else: + yield PASS, "OK" + + +'''@check( + id = 'org.sil/check/vertical_metrics', +# conditions = ['not remote_styles'], + rationale=""" + Based on com.google.fonts/check/vertical_metrics but with the 'not is_cjk' condition removed + """ +) +def org_sil_check_vertical_metrics(ttFont): + """Check font follows the Google Fonts vertical metric schema""" + filename = os.path.basename(ttFont.reader.file.name) + + # Check necessary tables are present. + missing_tables = False + required = ["OS/2", "hhea", "head"] + for key in required: + if key not in ttFont: + missing_tables = True + yield FAIL,\ + Message(f'lacks-{key}', + f"{filename} lacks a '{key}' table.") + + if missing_tables: + return + + font_upm = ttFont['head'].unitsPerEm + font_metrics = { + 'OS/2.sTypoAscender': ttFont['OS/2'].sTypoAscender, + 'OS/2.sTypoDescender': ttFont['OS/2'].sTypoDescender, + 'OS/2.sTypoLineGap': ttFont['OS/2'].sTypoLineGap, + 'hhea.ascent': ttFont['hhea'].ascent, + 'hhea.descent': ttFont['hhea'].descent, + 'hhea.lineGap': ttFont['hhea'].lineGap, + 'OS/2.usWinAscent': ttFont['OS/2'].usWinAscent, + 'OS/2.usWinDescent': ttFont['OS/2'].usWinDescent + } + expected_metrics = { + 'OS/2.sTypoLineGap': 0, + 'hhea.lineGap': 0, + } + + failed = False + warn = False + + # Check typo metrics and hhea lineGap match our expected values + for k in expected_metrics: + if font_metrics[k] != expected_metrics[k]: + failed = True + yield FAIL,\ + Message(f'bad-{k}', + f'{k} is "{font_metrics[k]}" it should be {expected_metrics[k]}') + + hhea_sum = (font_metrics['hhea.ascent'] + + abs(font_metrics['hhea.descent']) + + font_metrics['hhea.lineGap']) / font_upm + + # Check the sum of the hhea metrics is not below 1.2 + # (120% of upm or 1200 units for 1000 upm font) + if hhea_sum < 1.2: + failed = True + yield FAIL,\ + Message('bad-hhea-range', + 'The sum of hhea.ascender+abs(hhea.descender)+hhea.lineGap ' + f'is {int(hhea_sum*font_upm)} when it should be at least {int(font_upm*1.2)}') + + # Check the sum of the hhea metrics is below 2.0 + elif hhea_sum > 2.0: + failed = True + yield FAIL,\ + Message('bad-hhea-range', + 'The sum of hhea.ascender+abs(hhea.descender)+hhea.lineGap ' + f'is {int(hhea_sum*font_upm)} when it should be at most {int(font_upm*2.0)}') + + # Check the sum of the hhea metrics is between 1.1-1.5x of the font's upm + elif hhea_sum > 1.5: + warn = True + yield WARN,\ + Message('bad-hhea-range', + "We recommend the absolute sum of the hhea metrics should be" + f" between 1.2-1.5x of the font's upm. This font has {hhea_sum}x ({int(hhea_sum*font_upm)})") + + if not failed and not warn: + yield PASS, 'Vertical metrics are good' +''' + diff --git a/src/silfont/fbtests/silttfchecks.py b/src/silfont/fbtests/silttfchecks.py new file mode 100644 index 0000000..7be0ed5 --- /dev/null +++ b/src/silfont/fbtests/silttfchecks.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +'''Checks to be imported by ttfchecks.py +Some checks based on examples from Font Bakery, copyright 2017 The Font Bakery Authors, licensed under the Apache 2.0 license''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2022 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from fontbakery.status import PASS, FAIL, WARN, ERROR, INFO, SKIP +from fontbakery.callable import condition, check, disable +from fontbakery.message import Message +from fontbakery.constants import NameID, PlatformID, WindowsEncodingID + +@check( + id = 'org.sil/check/name/version_format', + rationale = """ + Based on com.google.fonts/check/name/version_format but: + - Checks for two valid formats: + - Production: exactly 3 digits after decimal point + + + - Allows major version to be 0 + - Allows extra info after numbers, eg for beta or dev versions + """ +) +def org_sil_version_format(ttFont): + "Version format is correct in 'name' table?" + + from fontbakery.utils import get_name_entry_strings + import re + + failed = False + version_entries = get_name_entry_strings(ttFont, NameID.VERSION_STRING) + if len(version_entries) == 0: + failed = True + yield FAIL,\ + Message("no-version-string", + f"Font lacks a NameID.VERSION_STRING" + f" (nameID={NameID.VERSION_STRING}) entry") + + for ventry in version_entries: + if not re.match(r'Version [0-9]+\.\d{3}( .+)*$', ventry): + failed = True + yield FAIL,\ + Message("bad-version-strings", + f'The NameID.VERSION_STRING' + f' (nameID={NameID.VERSION_STRING}) value must' + f' follow the pattern "Version X.nnn devstring" with X.nnn' + f' greater than or equal to 0.000.' + f' Current version string is: "{ventry}"') + if not failed: + yield PASS, "Version format in NAME table entries is correct." + +@check( + id = 'org.sil/check/whitespace_widths' +) +def org_sil_whitespace_widths(ttFont): + """Checks with widths of space characters in the font against best practice""" + from fontbakery.utils import get_glyph_name + + allok = True + space_data = { + 0x0020: ['Space'], + 0x00A0: ['No-break space'], + 0x2008: ['Punctuation space'], + 0x2003: ['Em space'], + 0x2002: ['En space'], + 0x2000: ['En quad'], + 0x2001: ['Em quad'], + 0x2004: ['Three-per-em space'], + 0x2005: ['Four-per-em space'], + 0x2006: ['Six-per-em space'], + 0x2009: ['Thin space'], + 0x200A: ['Hair space'], + 0x202F: ['Narrow no-break space'], + 0x002E: ['Full stop'], # Non-space character where the width is needed for comparison + } + for sp in space_data: + spname = get_glyph_name(ttFont, sp) + if spname is None: + spwidth = None + else: + spwidth = ttFont['hmtx'][spname][0] + space_data[sp].append(spname) + space_data[sp].append(spwidth) + + # Other width info needed from the font + upm = ttFont['head'].unitsPerEm + fullstopw = space_data[46][2] + + # Widths used for comparisons + spw = space_data[32][2] + if spw is None: + allok = False + yield WARN, "No space in the font so No-break space (if present) can't be checked" + emw = space_data[0x2003][2] + if emw is None: + allok = False + yield WARN, f'No em space in the font. Will be assumed to be units per em ({upm}) for other checking' + emw = upm + enw = space_data[0x2002][2] + if enw is None: + allok = False + yield WARN, f'No en space in the font. Will be assumed to be 1/2 em space width ({emw/2}) for checking en quad (if present)' + enw = emw/2 + + # Now check all the specific space widths. Only check if the space exists in the font + def checkspace(spacechar, minwidth, maxwidth=None): + sdata = space_data[spacechar] + if sdata[1]: # Name is set to None if not in font + # Allow for width(s) not being integer (eg em/6) so test against rounding up or down + minw = int(minwidth) + if maxwidth: + maxw = int(maxwidth) + if maxwidth > maxw: maxw += 1 # Had been rounded down, so round up + else: + maxw = minw if minw == minwidth else minw +1 # Had been rounded down, so allow rounded up as well + charw = sdata[2] + if not(minw <= charw <= maxw): + return (f'Width of {sdata[0]} ({spacechar:#04x}) is {str(charw)}: ', minw, maxw) + return (None,0,0) + + # No-break space + (message, minw, maxw) = checkspace(0x00A0, spw) + if message: allok = False; yield FAIL, message + f"Should match width of space ({spw})" + # Punctuation space + (message, minw, maxw) = checkspace(0x2008, fullstopw) + if message: allok = False; yield FAIL, message + f"Should match width of full stop ({fullstopw})" + # Em space + (message, minw, maxw) = checkspace(0x2003, upm) + if message: allok = False; yield WARN, message + f"Should match units per em ({upm})" + # En space + (message, minw, maxw) = checkspace(0x2002, emw/2) + if message: + allok = False + widths = f'{minw}' if minw == maxw else f'{minw} or {maxw}' + yield WARN, message + f"Should be half the width of em ({widths})" + # En quad + (message, minw, maxw) = checkspace(0x2000, enw) + if message: allok = False; yield WARN, message + f"Should be the same width as en ({enw})" + # Em quad + (message, minw, maxw) = checkspace(0x2001, emw) + if message: allok = False; yield WARN, message + f"Should be the same width as em ({emw})" + # Three-per-em space + (message, minw, maxw) = checkspace(0x2004, emw/3) + if message: + allok = False + widths = f'{minw}' if minw == maxw else f'{minw} or {maxw}' + yield WARN, message + f"Should be 1/3 the width of em ({widths})" + # Four-per-em space + (message, minw, maxw) = checkspace(0x2005, emw/4) + if message: + allok = False + widths = f'{minw}' if minw == maxw else f'{minw} or {maxw}' + yield WARN, message + f"Should be 1/4 the width of em ({widths})", + # Six-per-em space + (message, minw, maxw) = checkspace(0x2006, emw/6) + if message: + allok = False + widths = f'{minw}' if minw == maxw else f'{minw} or {maxw}' + yield WARN, message + f"Should be 1/6 the width of em ({widths})", + # Thin space + (message, minw, maxw) = checkspace(0x2009, emw/6, emw/5) + if message: + allok = False + yield WARN, message + f"Should be between 1/6 and 1/5 the width of em ({minw} and {maxw})" + # Hair space + (message, minw, maxw) = checkspace(0x200A, + emw/16, emw/10) + if message: + allok = False + yield WARN, message + f"Should be between 1/16 and 1/10 the width of em ({minw} and {maxw})" + # Narrow no-break space + (message, minw, maxw) = checkspace(0x202F, + emw/6, emw/5) + if message: + allok = False + yield WARN, message + f"Should be between 1/6 and 1/5 the width of em ({minw} and {maxw})" + + if allok: + yield PASS, "Space widths all match expected values" + +@check( + id = 'org.sil/check/number_widths' +) +def org_sil_number_widths(ttFont, config): + """Check widths of latin digits 0-9 are equal and match that of figure space""" + from fontbakery.utils import get_glyph_name + + num_data = { + 0x0030: ['zero'], + 0x0031: ['one'], + 0x0032: ['two'], + 0x0033: ['three'], + 0x0034: ['four'], + 0x0035: ['five'], + 0x0036: ['six'], + 0x0037: ['seven'], + 0x0038: ['eight'], + 0x0039: ['nine'], + 0x2007: ['figurespace'] # Figure space should be the same as numerals + } + + fontnames = [] + for x in (ttFont['name'].names[1].string, ttFont['name'].names[2].string): + txt="" + for i in range(1,len(x),2): txt += x.decode()[i] + fontnames.append(txt) + + for num in num_data: + name = get_glyph_name(ttFont, num) + if name is None: + width = -1 # So different from Zero! + else: + width = ttFont['hmtx'][name][0] + num_data[num].append(name) + num_data[num].append(width) + + zerowidth = num_data[48][2] + if zerowidth ==-1: + yield FAIL, "No zero in font - remainder of check not run" + return + + # Check non-zero digits are present and have same width as zero + digitsdiff = "" + digitsmissing = "" + for i in range(49,58): + ndata = num_data[i] + width = ndata[2] + if width != zerowidth: + if width == -1: + digitsmissing += ndata[1] + " " + else: + digitsdiff += ndata[1] + " " + + # Check figure space + figuremess = "" + ndata = num_data[0x2007] + width = ndata[2] + if width != zerowidth: + if width == -1: + figuremess = "No figure space in font" + else: + figuremess = f'The width of figure space ({ndata[1]}) does not match the width of zero' + if digitsmissing or digitsdiff or figuremess: + if digitsmissing: yield FAIL, f"Digits missing: {digitsmissing}" + if digitsdiff: yield WARN, f"Digits with different width from Zero: {digitsdiff}" + if figuremess: yield WARN, figuremess + else: + yield PASS, "All number widths are OK" diff --git a/src/silfont/fbtests/ttfchecks.py b/src/silfont/fbtests/ttfchecks.py new file mode 100644 index 0000000..afc004d --- /dev/null +++ b/src/silfont/fbtests/ttfchecks.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +'Support for use of Fontbakery ttf checks' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2020 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from fontbakery.section import Section +from fontbakery.status import PASS, FAIL, WARN, ERROR, INFO, SKIP +from fontbakery.fonts_profile import profile_factory +from fontbakery.profiles.googlefonts import METADATA_CHECKS, REPO_CHECKS, DESCRIPTION_CHECKS +from fontbakery.profiles.ufo_sources import UFO_PROFILE_CHECKS +from silfont.fbtests.silttfchecks import * +from silfont.fbtests.silnotcjk import * + + +from collections import OrderedDict + +# Set imports of standard ttf tests + +profile_imports = ("fontbakery.profiles.universal", + "fontbakery.profiles.googlefonts", + "fontbakery.profiles.adobefonts", + "fontbakery.profiles.notofonts", + "fontbakery.profiles.fontval") + +def make_base_profile(): + profile = profile_factory(default_section=Section("SIL Fonts")) + profile.auto_register(globals()) + + # Exclude groups of checks that check files other than ttfs + for checkid in DESCRIPTION_CHECKS + METADATA_CHECKS + REPO_CHECKS + UFO_PROFILE_CHECKS: + if checkid in profile._check_registry: profile.remove_check(checkid) + return profile + +def make_profile(check_list, variable_font=False): + profile = make_base_profile() + + # Exclude all the checks we don't want to run + for checkid in check_list: + if checkid in profile._check_registry: + check_item = check_list[checkid] + exclude = check_item["exclude"] if "exclude" in check_item else False + if exclude: profile.remove_check(checkid) + + # Exclude further sets of checks to reduce number of skips and so have less clutter in html results + for checkid in sorted(set(profile._check_registry.keys())): + section = profile._check_registry[checkid] + check = section.get_check(checkid) + conditions = getattr(check, "conditions") + exclude = False + if variable_font and "not is_variable_font" in conditions: exclude = True + if not variable_font and "is_variable_font" in conditions: exclude = True + if "noto" in checkid.lower(): exclude = True # These will be specific to Noto fonts + if ":adobefonts" in checkid.lower(): exclude = True # Copy of standard test with overridden results so no new info + + if exclude: profile.remove_check(checkid) + # Remove further checks that are only relevant for variable fonts but don't use the is_variable_font condition + if not variable_font: + for checkid in ( + "com.adobe.fonts/check/stat_has_axis_value_tables", + "com.google.fonts/check/STAT_strings", + "com.google.fonts/check/STAT/axis_order"): + if checkid in profile._check_registry.keys(): profile.remove_check(checkid) + return profile + +def all_checks_dict(): # An ordered dict of all checks designed for exporting the data + profile = make_base_profile() + check_dict=OrderedDict() + + for checkid in sorted(set(profile._check_registry.keys()), key=str.casefold): + if "noto" in checkid.lower(): continue # We wxclude these in make_profile() + if ":adobefonts" in checkid.lower(): continue # We wxclude these in make_profile() + + section = profile._check_registry[checkid] + check = section.get_check(checkid) + + conditions = getattr(check, "conditions") + conditionstxt="" + for condition in conditions: + conditionstxt += condition + "\n" + conditionstxt = conditionstxt.strip() + + rationale = getattr(check,"rationale") + rationale = "" if rationale is None else rationale.strip().replace("\n ", "\n") # Remove extraneous whitespace + + psfaction = psfcheck_list[checkid] if checkid in psfcheck_list else "Not in psfcheck_list" + + item = {"psfaction": psfaction, + "section": section.name, + "description": getattr(check, "description"), + "rationale": rationale, + "conditions": conditionstxt + } + check_dict[checkid] = item + + for checkid in psfcheck_list: # Look for checks no longer in Font Bakery + if checkid not in check_dict: + check_dict[checkid] = {"psfaction": psfcheck_list[checkid], + "section": "Missing", + "description": "Check not found", + "rationale": "", + "conditions": "" + } + + return check_dict + +psfcheck_list = {} +psfcheck_list['com.adobe.fonts/check/cff_call_depth'] = {'exclude': True} +psfcheck_list['com.adobe.fonts/check/cff_deprecated_operators'] = {'exclude': True} +psfcheck_list['com.adobe.fonts/check/cff2_call_depth'] = {'exclude': True} +psfcheck_list['com.adobe.fonts/check/family/consistent_family_name'] = {} +psfcheck_list['com.adobe.fonts/check/family/bold_italic_unique_for_nameid1'] = {} +psfcheck_list['com.adobe.fonts/check/family/consistent_upm'] = {} +psfcheck_list['com.adobe.fonts/check/family/max_4_fonts_per_family_name'] = {} +psfcheck_list['com.adobe.fonts/check/find_empty_letters'] = {} +psfcheck_list['com.adobe.fonts/check/freetype_rasterizer'] = {'exclude': True} +#psfcheck_list['com.adobe.fonts/check/freetype_rasterizer:googlefonts'] = {'exclude': True} +psfcheck_list['com.adobe.fonts/check/fsselection_matches_macstyle'] = {} +psfcheck_list['com.adobe.fonts/check/name/empty_records'] = {} +psfcheck_list['com.adobe.fonts/check/name/postscript_name_consistency'] = {} +psfcheck_list['com.adobe.fonts/check/nameid_1_win_english'] = {} +psfcheck_list['com.adobe.fonts/check/name/postscript_vs_cff'] = {'exclude': True} +psfcheck_list['com.adobe.fonts/check/sfnt_version'] = {} +psfcheck_list['com.adobe.fonts/check/stat_has_axis_value_tables'] = {} +psfcheck_list['com.adobe.fonts/check/STAT_strings'] = {'exclude': True} +psfcheck_list['com.adobe.fonts/check/unsupported_tables'] = {'exclude': True} +psfcheck_list['com.adobe.fonts/check/varfont/distinct_instance_records'] = {} +psfcheck_list['com.adobe.fonts/check/varfont/foundry_defined_tag_name'] = {} +psfcheck_list['com.adobe.fonts/check/varfont/same_size_instance_records'] = {} +psfcheck_list['com.adobe.fonts/check/varfont/valid_axis_nameid'] = {} +psfcheck_list['com.adobe.fonts/check/varfont/valid_default_instance_nameids'] = {} +psfcheck_list['com.adobe.fonts/check/varfont/valid_postscript_nameid'] = {} +psfcheck_list['com.adobe.fonts/check/varfont/valid_subfamily_nameid'] = {} +# psfcheck_list['com.fontwerk/check/inconsistencies_between_fvar_stat'] = {} # No longer in Font Bakery +# psfcheck_list['com.fontwerk/check/weight_class_fvar'] = {} # No longer in Font Bakery +psfcheck_list['com.google.fonts/check/aat'] = {} +# psfcheck_list['com.google.fonts/check/all_glyphs_have_codepoints'] = {'exclude': True} # No longer in Font Bakery +psfcheck_list['com.google.fonts/check/canonical_filename'] = {} +psfcheck_list['com.google.fonts/check/caret_slope'] = {} +psfcheck_list['com.google.fonts/check/cjk_chws_feature'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/cjk_not_enough_glyphs'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/cjk_vertical_metrics'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/cjk_vertical_metrics_regressions'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/cmap/alien_codepoints'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/cmap/format_12'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/cmap/unexpected_subtables'] = {} +psfcheck_list['com.google.fonts/check/color_cpal_brightness'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/colorfont_tables'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/code_pages'] = {} +psfcheck_list['com.google.fonts/check/contour_count'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/dotted_circle'] = {} +psfcheck_list['com.google.fonts/check/dsig'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/empty_glyph_on_gid1_for_colrv0'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/epar'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/family/control_chars'] = {} +psfcheck_list['com.google.fonts/check/family/equal_font_versions'] = {} +psfcheck_list['com.google.fonts/check/family/equal_unicode_encodings'] = {} +psfcheck_list['com.google.fonts/check/family/has_license'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/family/italics_have_roman_counterparts'] = {} +psfcheck_list['com.google.fonts/check/family/panose_familytype'] = {} +psfcheck_list['com.google.fonts/check/family/panose_proportion'] = {} +psfcheck_list['com.google.fonts/check/family/single_directory'] = {} +psfcheck_list['com.google.fonts/check/family/tnum_horizontal_metrics'] = {} +psfcheck_list['com.google.fonts/check/family/underline_thickness'] = {} +psfcheck_list['com.google.fonts/check/family/vertical_metrics'] = {} +psfcheck_list['com.google.fonts/check/family/win_ascent_and_descent'] = {'exclude': True} +# {'change_status': {'FAIL': 'WARN', 'reason': 'Under review'}} +psfcheck_list['com.google.fonts/check/family_naming_recommendations'] = {} +psfcheck_list['com.google.fonts/check/file_size'] = {} +psfcheck_list['com.google.fonts/check/font_copyright'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/font_names'] = {} +psfcheck_list['com.google.fonts/check/font_version'] = {} +psfcheck_list['com.google.fonts/check/fontbakery_version'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/fontdata_namecheck'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/fontv'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/fontvalidator'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/fsselection'] = {} +psfcheck_list['com.google.fonts/check/fstype'] = {} +psfcheck_list['com.google.fonts/check/fvar_instances'] = {} +psfcheck_list['com.google.fonts/check/fvar_name_entries'] = {} +psfcheck_list['com.google.fonts/check/gasp'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/gdef_mark_chars'] = {} +psfcheck_list['com.google.fonts/check/gdef_non_mark_chars'] = {} +psfcheck_list['com.google.fonts/check/gdef_spacing_marks'] = {} +psfcheck_list['com.google.fonts/check/gf_axisregistry/fvar_axis_defaults'] = {} +psfcheck_list['com.google.fonts/check/glyf_nested_components'] = {} +psfcheck_list['com.google.fonts/check/glyf_non_transformed_duplicate_components'] = {} +psfcheck_list['com.google.fonts/check/glyf_unused_data'] = {} +psfcheck_list['com.google.fonts/check/glyph_coverage'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/gpos7'] = {} +psfcheck_list['com.google.fonts/check/gpos_kerning_info'] = {} +psfcheck_list['com.google.fonts/check/has_ttfautohint_params'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/hinting_impact'] = {} +psfcheck_list['com.google.fonts/check/hmtx/comma_period'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/hmtx/encoded_latin_digits'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/hmtx/whitespace_advances'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/integer_ppem_if_hinted'] = {} +psfcheck_list['com.google.fonts/check/interpolation_issues'] = {} +psfcheck_list['com.google.fonts/check/italic_angle'] = {} +psfcheck_list['com.google.fonts/check/italic_angle:googlefonts'] = {} +psfcheck_list['com.google.fonts/check/italic_axis_in_stat'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/italic_axis_in_stat_is_boolean'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/italic_axis_in_stat_is_boolean:googlefonts']= {'exclude': True} +psfcheck_list['com.google.fonts/check/italic_axis_last'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/italic_axis_last:googlefonts'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/kern_table'] = {} +psfcheck_list['com.google.fonts/check/kerning_for_non_ligated_sequences'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/layout_valid_feature_tags'] = {} +psfcheck_list['com.google.fonts/check/layout_valid_language_tags'] = \ + {'change_status': {'FAIL': 'WARN', 'reason': 'The "invalid" ones are used by Harfbuzz'}} +psfcheck_list['com.google.fonts/check/layout_valid_script_tags'] = {} +psfcheck_list['com.google.fonts/check/ligature_carets'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/linegaps'] = {} +psfcheck_list['com.google.fonts/check/loca/maxp_num_glyphs'] = {} +psfcheck_list['com.google.fonts/check/mac_style'] = {} +psfcheck_list['com.google.fonts/check/mandatory_avar_table'] = {} +psfcheck_list['com.google.fonts/check/mandatory_glyphs'] = {} +psfcheck_list['com.google.fonts/check/math_signs_width'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/maxadvancewidth'] = {} +psfcheck_list['com.google.fonts/check/meta/script_lang_tags'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/missing_small_caps_glyphs'] = {} +psfcheck_list['com.google.fonts/check/monospace'] = {} +psfcheck_list['com.google.fonts/check/name/ascii_only_entries'] = {} +psfcheck_list['com.google.fonts/check/name/copyright_length'] = {} +psfcheck_list['com.google.fonts/check/name/description_max_length'] = {} +psfcheck_list['com.google.fonts/check/name/family_and_style_max_length'] = {} +psfcheck_list['com.google.fonts/check/name/family_name_compliance'] = {} +# psfcheck_list['com.google.fonts/check/name/familyname'] = {} # No longer in Font Bakery +psfcheck_list['com.google.fonts/check/name/familyname_first_char'] = {} +# psfcheck_list['com.google.fonts/check/name/fullfontname'] = {} # No longer in Font Bakery +psfcheck_list['com.google.fonts/check/name/italic_names'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/name/license'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/name/license_url'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/name/line_breaks'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/name/mandatory_entries'] = {} +psfcheck_list['com.google.fonts/check/name/match_familyname_fullfont'] = {} +psfcheck_list['com.google.fonts/check/name/no_copyright_on_description'] = {} +# psfcheck_list['com.google.fonts/check/name/postscriptname'] = {} # No longer in Font Bakery +psfcheck_list['com.google.fonts/check/name/rfn'] = {'exclude': True} +# psfcheck_list['com.google.fonts/check/name/subfamilyname'] = {} # No longer in Font Bakery +psfcheck_list['com.google.fonts/check/name/trailing_spaces'] = {'exclude': True} +# psfcheck_list['com.google.fonts/check/name/typographicfamilyname'] = {} # No longer in Font Bakery +# psfcheck_list['com.google.fonts/check/name/typographicsubfamilyname'] = {} # No longer in Font Bakery +psfcheck_list['com.google.fonts/check/name/unwanted_chars'] = {} +psfcheck_list['com.google.fonts/check/name/version_format'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/no_debugging_tables'] = {} +psfcheck_list['com.google.fonts/check/old_ttfautohint'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/os2/use_typo_metrics'] = {'exclude': True} +# psfcheck_list['com.google.fonts/check/os2/use_typo_metrics'] = \ (Left a copy commented out as an +# {'change_status': {'FAIL': 'WARN', 'reason': 'Under review'}} example of an override!) +psfcheck_list['com.google.fonts/check/os2_metrics_match_hhea'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/ots'] = {} +psfcheck_list['com.google.fonts/check/outline_alignment_miss'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/outline_colinear_vectors'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/outline_jaggy_segments'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/outline_semi_vertical'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/outline_short_segments'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/points_out_of_bounds'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/post_table_version'] = {} +psfcheck_list['com.google.fonts/check/production_glyphs_similarity'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/render_own_name'] = {} +psfcheck_list['com.google.fonts/check/required_tables'] = {} +psfcheck_list['com.google.fonts/check/rupee'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/shaping/collides'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/shaping/forbidden'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/shaping/regression'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/smart_dropout'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/slant_direction'] = {} +psfcheck_list['com.google.fonts/check/soft_dotted'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/soft_hyphen'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/STAT'] = {} +psfcheck_list['com.google.fonts/check/STAT/axis_order'] = {} +psfcheck_list['com.google.fonts/check/STAT/gf_axisregistry'] = {} +psfcheck_list['com.google.fonts/check/STAT_strings'] = {} +psfcheck_list['com.google.fonts/check/STAT_in_statics'] = {} +psfcheck_list['com.google.fonts/check/stylisticset_description'] = {} +psfcheck_list['com.google.fonts/check/superfamily/list'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/superfamily/vertical_metrics'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/transformed_components'] = {} +psfcheck_list['com.google.fonts/check/ttx_roundtrip'] = {} +psfcheck_list['com.google.fonts/check/unicode_range_bits'] = {} +psfcheck_list['com.google.fonts/check/unique_glyphnames'] = {} +psfcheck_list['com.google.fonts/check/unitsperem'] = {} +psfcheck_list['com.google.fonts/check/unitsperem_strict'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/unreachable_glyphs'] = {} +psfcheck_list['com.google.fonts/check/unwanted_tables'] = {} +psfcheck_list['com.google.fonts/check/usweightclass'] = {} +psfcheck_list['com.google.fonts/check/valid_glyphnames'] = {} +psfcheck_list['com.google.fonts/check/varfont_duplicate_instance_names'] = {} +# psfcheck_list['com.google.fonts/check/varfont_has_instances'] = {} # No longer in Font Bakery +# psfcheck_list['com.google.fonts/check/varfont_instance_coordinates'] = {} # No longer in Font Bakery +# psfcheck_list['com.google.fonts/check/varfont_instance_names'] = {} # No longer in Font Bakery +# psfcheck_list['com.google.fonts/check/varfont_weight_instances'] = {} # No longer in Font Bakery +psfcheck_list['com.google.fonts/check/varfont/bold_wght_coord'] = {} +psfcheck_list['com.google.fonts/check/varfont/consistent_axes'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/varfont/generate_static'] = {} +psfcheck_list['com.google.fonts/check/varfont/grade_reflow'] = {} +psfcheck_list['com.google.fonts/check/varfont/has_HVAR'] = {} +psfcheck_list['com.google.fonts/check/varfont/regular_ital_coord'] = {} +psfcheck_list['com.google.fonts/check/varfont/regular_opsz_coord'] = {} +psfcheck_list['com.google.fonts/check/varfont/regular_slnt_coord'] = {} +psfcheck_list['com.google.fonts/check/varfont/regular_wdth_coord'] = {} +psfcheck_list['com.google.fonts/check/varfont/regular_wght_coord'] = {} +psfcheck_list['com.google.fonts/check/varfont/slnt_range'] = {} +psfcheck_list['com.google.fonts/check/varfont/stat_axis_record_for_each_axis'] = {} +psfcheck_list['com.google.fonts/check/varfont/unsupported_axes'] = {} +psfcheck_list['com.google.fonts/check/varfont/wdth_valid_range'] = {} +psfcheck_list['com.google.fonts/check/varfont/wght_valid_range'] = {} +psfcheck_list['com.google.fonts/check/vendor_id'] = {} +psfcheck_list['com.google.fonts/check/version_bump'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/vertical_metrics'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/vertical_metrics_regressions'] = {'exclude': True} +psfcheck_list['com.google.fonts/check/vttclean'] = {} +psfcheck_list['com.google.fonts/check/whitespace_glyphnames'] = {} +psfcheck_list['com.google.fonts/check/whitespace_glyphs'] = {} +psfcheck_list['com.google.fonts/check/whitespace_ink'] = {} +psfcheck_list['com.google.fonts/check/whitespace_widths'] = {} +psfcheck_list['com.google.fonts/check/xavgcharwidth'] = {} +psfcheck_list['com.thetypefounders/check/vendor_id'] = {'exclude': True} +psfcheck_list['org.sil/check/family/win_ascent_and_descent'] = {} +psfcheck_list['org.sil/check/os2/use_typo_metrics'] = {} +psfcheck_list['org.sil/check/os2_metrics_match_hhea'] = {} +#psfcheck_list['org.sil/check/vertical_metrics'] = {} +psfcheck_list['org.sil/check/number_widths'] = {} +psfcheck_list['org.sil/check/name/version_format'] = {} +psfcheck_list['org.sil/check/whitespace_widths'] = {} + +profile = make_profile(check_list=psfcheck_list) diff --git a/src/silfont/feax_ast.py b/src/silfont/feax_ast.py new file mode 100644 index 0000000..13f76fb --- /dev/null +++ b/src/silfont/feax_ast.py @@ -0,0 +1,459 @@ +import ast as pyast +from fontTools.feaLib import ast +from fontTools.feaLib.ast import asFea +from fontTools.feaLib.error import FeatureLibError +import re, math + +def asFea(g): + if hasattr(g, 'asClassFea'): + return g.asClassFea() + elif hasattr(g, 'asFea'): + return g.asFea() + elif isinstance(g, tuple) and len(g) == 2: + return asFea(g[0]) + "-" + asFea(g[1]) # a range + elif g.lower() in ast.fea_keywords: + return "\\" + g + else: + return g + +ast.asFea = asFea +SHIFT = ast.SHIFT + +def asLiteralFea(self, indent=""): + Element.mode = 'literal' + return self.asFea(indent=indent) + Element.mode = 'flat' + +ast.Element.asLiteralFea = asLiteralFea +ast.Element.mode = 'flat' + +class ast_Comment(ast.Comment): + def __init__(self, text, location=None): + super(ast_Comment, self).__init__(text, location=location) + self.pretext = "" + self.posttext = "" + + def asFea(self, indent=""): + return self.pretext + self.text + self.posttext + +class ast_MarkClass(ast.MarkClass): + # This is better fixed upstream in parser.parse_glyphclass_ to handle MarkClasses + def asClassFea(self, indent=""): + return "[" + " ".join(map(asFea, self.glyphs)) + "]" + +class ast_BaseClass(ast_MarkClass) : + def asFea(self, indent="") : + return "@" + self.name + " = [" + " ".join(map(asFea, self.glyphs.keys())) + "];" + +class ast_BaseClassDefinition(ast.MarkClassDefinition): + def asFea(self, indent="") : + # like base class asFea + return ("# " if self.mode != 'literal' else "") + \ + "{}baseClass {} {} @{};".format(indent, self.glyphs.asFea(), + self.anchor.asFea(), self.markClass.name) + +class ast_MarkBasePosStatement(ast.MarkBasePosStatement): + def asFea(self, indent=""): + # handles members added by parse_position_base_ with feax syntax + if isinstance(self.base, ast.MarkClassName): # flattens pos @BASECLASS mark @MARKCLASS + res = "" + if self.mode == 'literal': + res += "pos base @{} ".format(self.base.markClass.name) + res += " ".join("mark @{}".format(m.name) for m in self.marks) + res += ";" + else: + for bcd in self.base.markClass.definitions: + if res != "": + res += "\n{}".format(indent) + res += "pos base {} {}".format(bcd.glyphs.asFea(), bcd.anchor.asFea()) + res += "".join(" mark @{}".format(m.name) for m in self.marks) + res += ";" + else: # like base class method + res = "pos base {}".format(self.base.asFea()) + res += "".join(" {} mark @{}".format(a.asFea(), m.name) for a, m in self.marks) + res += ";" + return res + + def build(self, builder) : + #TODO: do the right thing here (write to ttf?) + pass + +class ast_MarkMarkPosStatement(ast.MarkMarkPosStatement): + # super class __init__() for reference + # def __init__(self, location, baseMarks, marks): + # Statement.__init__(self, location) + # self.baseMarks, self.marks = baseMarks, marks + + def asFea(self, indent=""): + # handles members added by parse_position_base_ with feax syntax + if isinstance(self.baseMarks, ast.MarkClassName): # flattens pos @MARKCLASS mark @MARKCLASS + res = "" + if self.mode == 'literal': + res += "pos mark @{} ".format(self.base.markClass.name) + res += " ".join("mark @{}".format(m.name) for m in self.marks) + res += ";" + else: + for mcd in self.baseMarks.markClass.definitions: + if res != "": + res += "\n{}".format(indent) + res += "pos mark {} {}".format(mcd.glyphs.asFea(), mcd.anchor.asFea()) + for m in self.marks: + res += " mark @{}".format(m.name) + res += ";" + else: # like base class method + res = "pos mark {}".format(self.baseMarks.asFea()) + for a, m in self.marks: + res += " {} mark @{}".format(a.asFea() if a else "<anchor NULL>", m.name) + res += ";" + return res + + def build(self, builder): + # builder.add_mark_mark_pos(self.location, self.baseMarks.glyphSet(), self.marks) + #TODO: do the right thing + pass + +class ast_CursivePosStatement(ast.CursivePosStatement): + # super class __init__() for reference + # def __init__(self, location, glyphclass, entryAnchor, exitAnchor): + # Statement.__init__(self, location) + # self.glyphclass = glyphclass + # self.entryAnchor, self.exitAnchor = entryAnchor, exitAnchor + + def asFea(self, indent=""): + if isinstance(self.exitAnchor, ast.MarkClass): # pos cursive @BASE1 @BASE2 + res = "" + if self.mode == 'literal': + res += "pos cursive @{} @{};".format(self.glyphclass.name, self.exitAnchor.name) + else: + allglyphs = set(self.glyphclass.glyphSet()) + allglyphs.update(self.exitAnchor.glyphSet()) + for g in sorted(allglyphs): + entry = self.glyphclass.glyphs.get(g, None) + exit = self.exitAnchor.glyphs.get(g, None) + if res != "": + res += "\n{}".format(indent) + res += "pos cursive {} {} {};".format(g, + (entry.anchor.asFea() if entry else "<anchor NULL>"), + (exit.anchor.asFea() if exit else "<anchor NULL>")) + else: + res = super(ast_CursivePosStatement, self).asFea(indent) + return res + + def build(self, builder) : + #TODO: do the right thing here (write to ttf?) + pass + +class ast_MarkLigPosStatement(ast.MarkLigPosStatement): + def __init__(self, ligatures, marks, location=None): + ast.MarkLigPosStatement.__init__(self, ligatures, marks, location) + self.classBased = False + for l in marks: + if l is not None: + for m in l: + if m is not None and not isinstance(m[0], ast.Anchor): + self.classBased = True + break + + def build(self, builder): + builder.add_mark_lig_pos(self.location, self.ligatures.glyphSet(), self.marks) + + def asFea(self, indent=""): + if not self.classBased or self.mode == "literal": + return super(ast_MarkLigPosStatement, self).asFea(indent) + + res = [] + for g in self.ligatures.glyphSet(): + comps = [] + for l in self.marks: + onecomp = [] + if l is not None and len(l): + for a, m in l: + if not isinstance(a, ast.Anchor): + if g not in a.markClass.glyphs: + continue + left = a.markClass.glyphs[g].anchor.asFea() + else: + left = a.asFea() + onecomp.append("{} mark @{}".format(left, m.name)) + if not len(onecomp): + onecomp = ["<anchor NULL>"] + comps.append(" ".join(onecomp)) + res.append("pos ligature {} ".format(asFea(g)) + ("\n"+indent+SHIFT+"ligComponent ").join(comps)) + return (";\n"+indent).join(res) + ";" + +#similar to ast.MultipleSubstStatement +#one-to-many substitution, one glyph class is on LHS, multiple glyph classes may be on RHS +# equivalent to generation of one stmt for each glyph in the LHS class +# that's matched to corresponding glyphs in the RHS classes +#prefix and suffx are for contextual lookups and do not need processing +#replacement could contain multiple slots +#TODO: below only supports one RHS class? +class ast_MultipleSubstStatement(ast.Statement): + def __init__(self, prefix, glyph, suffix, replacement, forceChain, location=None): + ast.Statement.__init__(self, location) + self.prefix, self.glyph, self.suffix = prefix, glyph, suffix + self.replacement = replacement + self.forceChain = forceChain + lenglyphs = len(self.glyph.glyphSet()) + for i, r in enumerate(self.replacement) : + if len(r.glyphSet()) == lenglyphs: + self.multindex = i #first RHS slot with a glyph class + break + else: + if lenglyphs > 1: + raise FeatureLibError("No replacement class is of the same length as the matching class", + location) + else: + self.multindex = 0; + + def build(self, builder): + prefix = [p.glyphSet() for p in self.prefix] + suffix = [s.glyphSet() for s in self.suffix] + glyphs = self.glyph.glyphSet() + replacements = self.replacement[self.multindex].glyphSet() + lenglyphs = len(glyphs) + for i in range(max(lenglyphs, len(replacements))) : + builder.add_multiple_subst( + self.location, prefix, glyphs[i if lenglyphs > 1 else 0], suffix, + self.replacement[0:self.multindex] + [replacements[i]] + self.replacement[self.multindex+1:], + self.forceChain) + + def asFea(self, indent=""): + res = "" + pres = (" ".join(map(asFea, self.prefix)) + " ") if len(self.prefix) else "" + sufs = (" " + " ".join(map(asFea, self.suffix))) if len(self.suffix) else "" + mark = "'" if len(self.prefix) or len(self.suffix) or self.forceChain else "" + if self.mode == 'literal': + res += "sub " + pres + self.glyph.asFea() + mark + sufs + " by " + res += " ".join(asFea(g) for g in self.replacement) + ";" + return res + glyphs = self.glyph.glyphSet() + replacements = self.replacement[self.multindex].glyphSet() + lenglyphs = len(glyphs) + count = max(lenglyphs, len(replacements)) + for i in range(count) : + res += ("\n" + indent if i > 0 else "") + "sub " + pres + res += asFea(glyphs[i if lenglyphs > 1 else 0]) + mark + sufs + res += " by " + res += " ".join(asFea(g) for g in self.replacement[0:self.multindex] + [replacements[i]] + self.replacement[self.multindex+1:]) + res += ";" + return res + + +# similar to ast.LigatureSubstStatement +# many-to-one substitution, one glyph class is on RHS, multiple glyph classes may be on LHS +# equivalent to generation of one stmt for each glyph in the RHS class +# that's matched to corresponding glyphs in the LHS classes +# it's unclear which LHS class should correspond to the RHS class +# prefix and suffx are for contextual lookups and do not need processing +# replacement could contain multiple slots +#TODO: below only supports one LHS class? +class ast_LigatureSubstStatement(ast.Statement): + def __init__(self, prefix, glyphs, suffix, replacement, + forceChain, location=None): + ast.Statement.__init__(self, location) + self.prefix, self.glyphs, self.suffix = (prefix, glyphs, suffix) + self.replacement, self.forceChain = replacement, forceChain + lenreplace = len(self.replacement.glyphSet()) + for i, g in enumerate(self.glyphs): + if len(g.glyphSet()) == lenreplace: + self.multindex = i #first LHS slot with a glyph class + break + else: + if lenreplace > 1: + raise FeatureLibError("No class matches replacement class length", location) + else: + self.multindex = 0 + + def build(self, builder): + prefix = [p.glyphSet() for p in self.prefix] + glyphs = [g.glyphSet() for g in self.glyphs] + suffix = [s.glyphSet() for s in self.suffix] + replacements = self.replacement.glyphSet() + lenreplace = len(replacements.glyphSet()) + glyphs = self.glyphs[self.multindex].glyphSet() + for i in range(max(len(glyphs), len(replacements))): + builder.add_ligature_subst( + self.location, prefix, + self.glyphs[:self.multindex] + glyphs[i] + self.glyphs[self.multindex+1:], + suffix, replacements[i if lenreplace > 1 else 0], self.forceChain) + + def asFea(self, indent=""): + res = "" + pres = (" ".join(map(asFea, self.prefix)) + " ") if len(self.prefix) else "" + sufs = (" " + " ".join(map(asFea, self.suffix))) if len(self.suffix) else "" + mark = "'" if len(self.prefix) or len(self.suffix) or self.forceChain else "" + if self.mode == 'literal': + res += "sub " + pres + " ".join(asFea(g)+mark for g in self.glyphs) + sufs + " by " + res += self.replacements.asFea() + ";" + return res + glyphs = self.glyphs[self.multindex].glyphSet() + replacements = self.replacement.glyphSet() + lenreplace = len(replacements) + count = max(len(glyphs), len(replacements)) + for i in range(count) : + res += ("\n" + indent if i > 0 else "") + "sub " + pres + res += " ".join(asFea(g)+mark for g in self.glyphs[:self.multindex] + [glyphs[i]] + self.glyphs[self.multindex+1:]) + res += sufs + " by " + res += asFea(replacements[i if lenreplace > 1 else 0]) + res += ";" + return res + +class ast_AlternateSubstStatement(ast.Statement): + def __init__(self, prefix, glyphs, suffix, replacements, location=None): + ast.Statement.__init__(self, location) + self.prefix, self.glyphs, self.suffix = (prefix, glyphs, suffix) + self.replacements = replacements + + def build(self, builder): + prefix = [p.glyphSet() for p in self.prefix] + suffix = [s.glyphSet() for s in self.suffix] + l = len(self.glyphs.glyphSet()) + for i, glyph in enumerate(self.glyphs.glyphSet()): + replacement = self.replacements.glyphSet()[i::l] + builder.add_alternate_subst(self.location, prefix, glyph, suffix, + replacement) + + def asFea(self, indent=""): + res = "" + l = len(self.glyphs.glyphSet()) + for i, glyph in enumerate(self.glyphs.glyphSet()): + if i > 0: + res += "\n" + indent + res += "sub " + if len(self.prefix) or len(self.suffix): + if len(self.prefix): + res += " ".join(map(asFea, self.prefix)) + " " + res += asFea(glyph) + "'" # even though we really only use 1 + if len(self.suffix): + res += " " + " ".join(map(asFea, self.suffix)) + else: + res += asFea(glyph) + res += " from " + replacements = ast.GlyphClass(glyphs=self.replacements.glyphSet()[i::l], location=self.location) + res += asFea(replacements) + res += ";" + return res + +class ast_IfBlock(ast.Block): + def __init__(self, testfn, name, cond, location=None): + ast.Block.__init__(self, location=location) + self.testfn = testfn + self.name = name + + def asFea(self, indent=""): + if self.mode == 'literal': + res = "{}if{}({}) {{".format(indent, name, cond) + res += ast.Block.asFea(self, indent=indent) + res += indent + "}\n" + return res + elif self.testfn(): + return ast.Block.asFea(self, indent=indent) + else: + return "" + + +class ast_DoSubStatement(ast.Statement): + def __init__(self, varnames, location=None): + ast.Statement.__init__(self, location=location) + self.names = varnames + + def items(self, variables): + yield ((None, None),) + +class ast_DoForSubStatement(ast_DoSubStatement): + def __init__(self, varname, glyphs, location=None): + ast_DoSubStatement.__init__(self, [varname], location=location) + self.glyphs = glyphs.glyphSet() + + def items(self, variables): + for g in self.glyphs: + yield((self.names[0], g),) + +def safeeval(exp): + # no dunders in attribute names + for n in pyast.walk(pyast.parse(exp)): + v = getattr(n, 'id', "") + # if v in ('_getiter_', '__next__'): + # continue + if "__" in v: + return False + return True + +class ast_DoLetSubStatement(ast_DoSubStatement): + def __init__(self, varnames, expression, parser, location=None): + ast_DoSubStatement.__init__(self, varnames, location=location) + self.parser = parser + if not safeeval(expression): + expression='"Unsafe Expression"' + self.expr = expression + + def items(self, variables): + gbls = dict(self.parser.fns, **variables) + try: + v = eval(self.expr, gbls) + except Exception as e: + raise FeatureLibError(str(e) + " in " + self.expr, self.location) + if self.names is None: # in an if + yield((None, v),) + elif len(self.names) == 1: + yield((self.names[0], v),) + else: + yield(zip(self.names, list(v) + [None] * (len(self.names) - len(v)))) + +class ast_DoForLetSubStatement(ast_DoLetSubStatement): + def items(self, variables): + gbls = dict(self.parser.fns, **variables) + try: + v = eval(self.expr, gbls) + except Exception as e: + raise FeatureLibError(str(e) + " in " + self.expr, self.location) + if len(self.names) == 1: + for e in v: + yield((self.names[0], e),) + else: + for e in v: + yield(zip(self.names, list(e) + [None] * (len(self.names) - len(e)))) + +class ast_DoIfSubStatement(ast_DoLetSubStatement): + def __init__(self, expression, parser, block, location=None): + ast_DoLetSubStatement.__init__(self, None, expression, parser, location=None) + self.block = block + + def items(self, variables): + (_, v) = list(ast_DoLetSubStatement.items(self, variables))[0][0] + yield (None, (v if v else None),) + +class ast_KernPairsStatement(ast.Statement): + def __init__(self, kerninfo, location=None): + super(ast_KernPairsStatement, self).__init__(location) + self.kerninfo = kerninfo + + def asFea(self, indent=""): + # return ("\n"+indent).join("pos {} {} {};".format(k1, round(v), k2) \ + # for k1, x in self.kerninfo.items() for k2, v in x.items()) + coverage = set() + rules = dict() + + # first sort into lists by type of rule + for k1, x in self.kerninfo.items(): + for k2, v in x.items(): + # Determine pair kern type, where: + # 'gg' = glyph-glyph, 'gc' = glyph-class', 'cg' = class-glyph, 'cc' = class-class + ruleType = 'gc'[k1[0]=='@'] + 'gc'[k2[0]=='@'] + rules.setdefault(ruleType, list()).append([k1, round(v), k2]) + # for glyph-glyph rules, make list of first glyphs: + if ruleType == 'gg': + coverage.add(k1) + + # Now assemble lines in order and convert gc rules to gg where possible: + res = [] + for ruleType in filter(lambda x: x in rules, ('gg', 'gc', 'cg', 'cc')): + if ruleType != 'gc': + res.extend(['pos {} {} {};'.format(k1, v, k2) for k1,v,k2 in rules[ruleType]]) + else: + res.extend(['enum pos {} {} {};'.format(k1, v, k2) for k1, v, k2 in rules[ruleType] if k1 not in coverage]) + res.extend(['pos {} {} {};'.format(k1, v, k2) for k1, v, k2 in rules[ruleType] if k1 in coverage]) + + return ("\n"+indent).join(res) + diff --git a/src/silfont/feax_lexer.py b/src/silfont/feax_lexer.py new file mode 100644 index 0000000..77764ef --- /dev/null +++ b/src/silfont/feax_lexer.py @@ -0,0 +1,105 @@ +from fontTools.feaLib.lexer import IncludingLexer, Lexer +from fontTools.feaLib.error import FeatureLibError +import re, io + +VARIABLE = "VARIABLE" + +class feax_Lexer(Lexer): + + def __init__(self, *a): + Lexer.__init__(self, *a) + self.tokens_ = None + self.stack_ = [] + self.empty_ = False + + def next_(self, recurse=False): + while (not self.empty_): + if self.tokens_ is not None: + res = self.tokens_.pop(0) + if not len(self.tokens_): + self.popstack() + if res[0] != VARIABLE: + return (res[0], res[1], self.location_()) + return self.parse_variable(res[1]) + + try: + res = Lexer.next_(self) + except IndexError as e: + self.popstack() + continue + except StopIteration as e: + self.popstack() + continue + except FeatureLibError as e: + if u"Unexpected character" not in str(e): + raise e + + # only executes if exception occurred + location = self.location_() + text = self.text_ + start = self.pos_ + cur_char = text[start] + if cur_char == '$': + self.pos_ += 1 + self.scan_over_(Lexer.CHAR_NAME_CONTINUATION_) + varname = text[start+1:self.pos_] + if len(varname) < 1 or len(varname) > 63: + raise FeatureLibError("Bad variable name length for: %s" % varname, location) + res = (VARIABLE, varname, location) + else: + raise FeatureLibError("Unexpected character: %r" % cur_char, location) + return res + raise StopIteration + + def __repr__(self): + if self.tokens_ is not None: + return str(self.tokens_) + else: + return str((self.text_[self.pos_:self.pos_+20], self.pos_, self.text_length_)) + + def popstack(self): + if len(self.stack_) == 0: + self.empty_ = True + return + t = self.stack_.pop() + if t[0] == 'tokens': + self.tokens_ = t[1] + else: + self.text_, self.pos_, self.text_length_ = t[1] + self.tokens_ = None + + def pushstack(self, v): + if self.tokens_ is None: + self.stack_.append(('text', (self.text_, self.pos_, self.text_length_))) + else: + self.stack_.append(('tokens', self.tokens_)) + self.stack_.append(v) + self.popstack() + + def pushback(self, token_type, token): + if self.tokens_ is not None: + self.tokens_.append((token_type, token)) + else: + self.pushstack(('tokens', [(token_type, token)])) + + def parse_variable(self, vname): + t = str(self.scope.get(vname, '')) + if t != '': + self.pushstack(['text', (t + " ", 0, len(t)+1)]) + return self.next_() + +class feax_IncludingLexer(IncludingLexer): + + @staticmethod + def make_lexer_(file_or_path): + if hasattr(file_or_path, "read"): + fileobj, closing = file_or_path, False + else: + filename, closing = file_or_path, True + fileobj = io.open(filename, mode="r", encoding="utf-8") + data = fileobj.read() + filename = getattr(fileobj, "name", None) + if closing: + fileobj.close() + return feax_Lexer(data, filename) + diff --git a/src/silfont/feax_parser.py b/src/silfont/feax_parser.py new file mode 100644 index 0000000..c59b1e0 --- /dev/null +++ b/src/silfont/feax_parser.py @@ -0,0 +1,736 @@ +from fontTools.feaLib import ast +from fontTools.feaLib.parser import Parser +from fontTools.feaLib.lexer import IncludingLexer, Lexer +import silfont.feax_lexer as feax_lexer +from fontTools.feaLib.error import FeatureLibError +import silfont.feax_ast as astx +import io, re, math, os +import logging + +class feaplus_ast(object) : + MarkBasePosStatement = astx.ast_MarkBasePosStatement + MarkMarkPosStatement = astx.ast_MarkMarkPosStatement + MarkLigPosStatement = astx.ast_MarkLigPosStatement + CursivePosStatement = astx.ast_CursivePosStatement + BaseClass = astx.ast_BaseClass + MarkClass = astx.ast_MarkClass + BaseClassDefinition = astx.ast_BaseClassDefinition + MultipleSubstStatement = astx.ast_MultipleSubstStatement + LigatureSubstStatement = astx.ast_LigatureSubstStatement + IfBlock = astx.ast_IfBlock + DoForSubStatement = astx.ast_DoForSubStatement + DoForLetSubStatement = astx.ast_DoForLetSubStatement + DoLetSubStatement = astx.ast_DoLetSubStatement + DoIfSubStatement = astx.ast_DoIfSubStatement + AlternateSubstStatement = astx.ast_AlternateSubstStatement + Comment = astx.ast_Comment + KernPairsStatement = astx.ast_KernPairsStatement + + def __getattr__(self, name): + return getattr(ast, name) # retrieve undefined attrs from imported fontTools.feaLib ast module + +class feaplus_parser(Parser) : + extensions = { + 'baseClass': lambda s: s.parseBaseClass(), + 'ifclass': lambda s: s.parseIfClass(), + 'ifinfo': lambda s: s.parseIfInfo(), + 'do': lambda s: s.parseDoStatement_(), + 'def': lambda s: s.parseDefStatement_(), + 'kernpairs': lambda s: s.parseKernPairsStatement_() + } + ast = feaplus_ast() + + def __init__(self, filename, glyphmap, fontinfo, kerninfo, defines) : + if filename is None : + empty_file = io.StringIO("") + super(feaplus_parser, self).__init__(empty_file, glyphmap) + else : + super(feaplus_parser, self).__init__(filename, glyphmap) + self.fontinfo = fontinfo + self.kerninfo = kerninfo + self.glyphs = glyphmap + self.defines = defines + self.fns = { + '__builtins__': None, + 're' : re, + 'math' : math, + 'APx': lambda g, a, d=0: int(self.glyphs[g].anchors.get(a, [d])[0]), + 'APy': lambda g, a, d=0: int(self.glyphs[g].anchors.get(a, [0,d])[1]), + 'ADVx': lambda g: int(self.glyphs[g].advance), + 'MINx': lambda g: int(self.glyphs[g].bbox[0]), + 'MINy': lambda g: int(self.glyphs[g].bbox[1]), + 'MAXx': lambda g: int(self.glyphs[g].bbox[2]), + 'MAXy': lambda g: int(self.glyphs[g].bbox[3]), + 'feaclass': lambda c: self.resolve_glyphclass(c).glyphSet(), + 'allglyphs': lambda : self.glyphs.keys(), + 'lf': lambda : "\n", + 'info': lambda s: self.fontinfo.get(s, ""), + 'fileexists': lambda s: os.path.exists(s), + 'kerninfo': lambda s:[(k1, k2, v) for k1, x in self.kerninfo.items() for k2, v in x.items()], + 'opt': lambda s: self.defines.get(s, "") + } + # Document which builtins we really need. Of course still insecure. + for x in ('True', 'False', 'None', 'int', 'float', 'str', 'abs', 'all', 'any', 'bool', + 'dict', 'enumerate', 'filter', 'hasattr', 'hex', 'isinstance', 'len', 'list', 'map', 'print', + 'max', 'min', 'ord', 'range', 'set', 'sorted', 'sum', 'tuple', 'type', 'zip'): + self.fns[x] = __builtins__[x] + + def parse(self, filename=None) : + if filename is not None : + self.lexer_ = feax_lexer.feax_IncludingLexer(filename) + self.advance_lexer_(comments=True) + return super(feaplus_parser, self).parse() + + def back_lexer_(self): + self.lexer_.lexers_[-1].pushback(self.next_token_type_, self.next_token_) + self.next_token_type_ = self.cur_token_type_ + self.next_token_ = self.cur_token_ + self.next_token_location_ = self.cur_token_location_ + + # methods to limit layer violations + def define_glyphclass(self, ap_nm, gc) : + self.glyphclasses_.define(ap_nm, gc) + + def resolve_glyphclass(self, ap_nm): + try: + return self.glyphclasses_.resolve(ap_nm) + except KeyError: + raise FeatureLibError("Glyphclass '{}' missing".format(ap_nm), self.lexer_.location_()) + return None + + def add_statement(self, val) : + self.doc_.statements.append(val) + + def set_baseclass(self, ap_nm) : + gc = self.ast.BaseClass(ap_nm) + if not hasattr(self.doc_, 'baseClasses') : + self.doc_.baseClasses = {} + self.doc_.baseClasses[ap_nm] = gc + self.define_glyphclass(ap_nm, gc) + return gc + + def set_markclass(self, ap_nm) : + gc = self.ast.MarkClass(ap_nm) + if not hasattr(self.doc_, 'markClasses') : + self.doc_.markClasses = {} + self.doc_.markClasses[ap_nm] = gc + self.define_glyphclass(ap_nm, gc) + return gc + + + # like base class parse_position_base_ & overrides it + def parse_position_base_(self, enumerated, vertical): + location = self.cur_token_location_ + self.expect_keyword_("base") + if enumerated: + raise FeatureLibError( + '"enumerate" is not allowed with ' + 'mark-to-base attachment positioning', + location) + base = self.parse_glyphclass_(accept_glyphname=True) + if self.next_token_ == "<": # handle pos base [glyphs] <anchor> mark @MARKCLASS + marks = self.parse_anchor_marks_() + else: # handle pos base @BASECLASS mark @MARKCLASS; like base class parse_anchor_marks_ + marks = [] + while self.next_token_ == "mark": #TODO: is more than one 'mark' meaningful? + self.expect_keyword_("mark") + m = self.expect_markClass_reference_() + marks.append(m) + self.expect_symbol_(";") + return self.ast.MarkBasePosStatement(base, marks, location=location) + + # like base class parse_position_mark_ & overrides it + def parse_position_mark_(self, enumerated, vertical): + location = self.cur_token_location_ + self.expect_keyword_("mark") + if enumerated: + raise FeatureLibError( + '"enumerate" is not allowed with ' + 'mark-to-mark attachment positioning', + location) + baseMarks = self.parse_glyphclass_(accept_glyphname=True) + if self.next_token_ == "<": # handle pos mark [glyphs] <anchor> mark @MARKCLASS + marks = self.parse_anchor_marks_() + else: # handle pos mark @MARKCLASS mark @MARKCLASS; like base class parse_anchor_marks_ + marks = [] + while self.next_token_ == "mark": #TODO: is more than one 'mark' meaningful? + self.expect_keyword_("mark") + m = self.expect_markClass_reference_() + marks.append(m) + self.expect_symbol_(";") + return self.ast.MarkMarkPosStatement(baseMarks, marks, location=location) + + def parse_position_cursive_(self, enumerated, vertical): + location = self.cur_token_location_ + self.expect_keyword_("cursive") + if enumerated: + raise FeatureLibError( + '"enumerate" is not allowed with ' + 'cursive attachment positioning', + location) + glyphclass = self.parse_glyphclass_(accept_glyphname=True) + if self.next_token_ == "<": # handle pos cursive @glyphClass <anchor entry> <anchor exit> + entryAnchor = self.parse_anchor_() + exitAnchor = self.parse_anchor_() + self.expect_symbol_(";") + return self.ast.CursivePosStatement( + glyphclass, entryAnchor, exitAnchor, location=location) + else: # handle pos cursive @baseClass @baseClass; + mc = self.expect_markClass_reference_() + return self.ast.CursivePosStatement(glyphclass.markClass, None, mc, location=location) + + def parse_position_ligature_(self, enumerated, vertical): + location = self.cur_token_location_ + self.expect_keyword_("ligature") + if enumerated: + raise FeatureLibError( + '"enumerate" is not allowed with ' + 'mark-to-ligature attachment positioning', + location) + ligatures = self.parse_glyphclass_(accept_glyphname=True) + marks = [self._parse_anchorclass_marks_()] + while self.next_token_ == "ligComponent": + self.expect_keyword_("ligComponent") + marks.append(self._parse_anchorclass_marks_()) + self.expect_symbol_(";") + return self.ast.MarkLigPosStatement(ligatures, marks, location=location) + + def _parse_anchorclass_marks_(self): + """Parses a sequence of [<anchor> | @BASECLASS mark @MARKCLASS]*.""" + anchorMarks = [] # [(self.ast.Anchor, markClassName)*] + while True: + if self.next_token_ == "<": + anchor = self.parse_anchor_() + else: + anchor = self.parse_glyphclass_(accept_glyphname=False) + if anchor is not None: + self.expect_keyword_("mark") + markClass = self.expect_markClass_reference_() + anchorMarks.append((anchor, markClass)) + if self.next_token_ == "ligComponent" or self.next_token_ == ";": + break + return anchorMarks + + # like base class parseMarkClass + # but uses BaseClass and BaseClassDefinition which subclass Mark counterparts + def parseBaseClass(self): + if not hasattr(self.doc_, 'baseClasses'): + self.doc_.baseClasses = {} + location = self.cur_token_location_ + glyphs = self.parse_glyphclass_(accept_glyphname=True) + anchor = self.parse_anchor_() + name = self.expect_class_name_() + self.expect_symbol_(";") + baseClass = self.doc_.baseClasses.get(name) + if baseClass is None: + baseClass = self.ast.BaseClass(name) + self.doc_.baseClasses[name] = baseClass + self.glyphclasses_.define(name, baseClass) + bcdef = self.ast.BaseClassDefinition(baseClass, anchor, glyphs, location=location) + baseClass.addDefinition(bcdef) + return bcdef + + #similar to and overrides parser.parse_substitute_ + def parse_substitute_(self): + assert self.cur_token_ in {"substitute", "sub", "reversesub", "rsub"} + location = self.cur_token_location_ + reverse = self.cur_token_ in {"reversesub", "rsub"} + old_prefix, old, lookups, values, old_suffix, hasMarks = \ + self.parse_glyph_pattern_(vertical=False) + if any(values): + raise FeatureLibError( + "Substitution statements cannot contain values", location) + new = [] + if self.next_token_ == "by": + keyword = self.expect_keyword_("by") + while self.next_token_ != ";": + gc = self.parse_glyphclass_(accept_glyphname=True) + new.append(gc) + elif self.next_token_ == "from": + keyword = self.expect_keyword_("from") + new = [self.parse_glyphclass_(accept_glyphname=False)] + else: + keyword = None + self.expect_symbol_(";") + if len(new) == 0 and not any(lookups): + raise FeatureLibError( + 'Expected "by", "from" or explicit lookup references', + self.cur_token_location_) + + # GSUB lookup type 3: Alternate substitution. + # Format: "substitute a from [a.1 a.2 a.3];" + if keyword == "from": + if reverse: + raise FeatureLibError( + 'Reverse chaining substitutions do not support "from"', + location) + # allow classes on lhs + if len(old) != 1: + raise FeatureLibError( + 'Expected single glyph or glyph class before "from"', + location) + if len(new) != 1: + raise FeatureLibError( + 'Expected a single glyphclass after "from"', + location) + if len(old[0].glyphSet()) == 0 or len(new[0].glyphSet()) % len(old[0].glyphSet()) != 0: + raise FeatureLibError( + 'The glyphclass after "from" must be a multiple of length of the glyphclass on before', + location) + return self.ast.AlternateSubstStatement( + old_prefix, old[0], old_suffix, new[0], location=location) + + num_lookups = len([l for l in lookups if l is not None]) + + # GSUB lookup type 1: Single substitution. + # Format A: "substitute a by a.sc;" + # Format B: "substitute [one.fitted one.oldstyle] by one;" + # Format C: "substitute [a-d] by [A.sc-D.sc];" + if (not reverse and len(old) == 1 and len(new) == 1 and + num_lookups == 0): + glyphs = list(old[0].glyphSet()) + replacements = list(new[0].glyphSet()) + if len(replacements) == 1: + replacements = replacements * len(glyphs) + if len(glyphs) != len(replacements): + raise FeatureLibError( + 'Expected a glyph class with %d elements after "by", ' + 'but found a glyph class with %d elements' % + (len(glyphs), len(replacements)), location) + return self.ast.SingleSubstStatement( + old, new, + old_prefix, old_suffix, + forceChain=hasMarks, location=location + ) + + # GSUB lookup type 2: Multiple substitution. + # Format: "substitute f_f_i by f f i;" + if (not reverse and + len(old) == 1 and len(new) > 1 and num_lookups == 0): + return self.ast.MultipleSubstStatement(old_prefix, old[0], old_suffix, new, + hasMarks, location=location) + + # GSUB lookup type 4: Ligature substitution. + # Format: "substitute f f i by f_f_i;" + if (not reverse and + len(old) > 1 and len(new) == 1 and num_lookups == 0): + return self.ast.LigatureSubstStatement(old_prefix, old, old_suffix, new[0], + forceChain=hasMarks, location=location) + + # GSUB lookup type 8: Reverse chaining substitution. + if reverse: + if len(old) != 1: + raise FeatureLibError( + "In reverse chaining single substitutions, " + "only a single glyph or glyph class can be replaced", + location) + if len(new) != 1: + raise FeatureLibError( + 'In reverse chaining single substitutions, ' + 'the replacement (after "by") must be a single glyph ' + 'or glyph class', location) + if num_lookups != 0: + raise FeatureLibError( + "Reverse chaining substitutions cannot call named lookups", + location) + glyphs = sorted(list(old[0].glyphSet())) + replacements = sorted(list(new[0].glyphSet())) + if len(replacements) == 1: + replacements = replacements * len(glyphs) + if len(glyphs) != len(replacements): + raise FeatureLibError( + 'Expected a glyph class with %d elements after "by", ' + 'but found a glyph class with %d elements' % + (len(glyphs), len(replacements)), location) + return self.ast.ReverseChainSingleSubstStatement( + old_prefix, old_suffix, old, new, location=location) + + # GSUB lookup type 6: Chaining contextual substitution. + assert len(new) == 0, new + rule = self.ast.ChainContextSubstStatement( + old_prefix, old, old_suffix, lookups, location=location) + return rule + + def parse_glyphclass_(self, accept_glyphname): + if (accept_glyphname and + self.next_token_type_ in (Lexer.NAME, Lexer.CID)): + glyph = self.expect_glyph_() + return self.ast.GlyphName(glyph, location=self.cur_token_location_) + if self.next_token_type_ is Lexer.GLYPHCLASS: + self.advance_lexer_() + gc = self.glyphclasses_.resolve(self.cur_token_) + if gc is None: + raise FeatureLibError( + "Unknown glyph class @%s" % self.cur_token_, + self.cur_token_location_) + if isinstance(gc, self.ast.MarkClass): + return self.ast.MarkClassName(gc, location=self.cur_token_location_) + else: + return self.ast.GlyphClassName(gc, location=self.cur_token_location_) + + self.expect_symbol_("[") + location = self.cur_token_location_ + glyphs = self.ast.GlyphClass(location=location) + while self.next_token_ != "]": + if self.next_token_type_ is Lexer.NAME: + glyph = self.expect_glyph_() + location = self.cur_token_location_ + if '-' in glyph and glyph not in self.glyphNames_: + start, limit = self.split_glyph_range_(glyph, location) + glyphs.add_range( + start, limit, + self.make_glyph_range_(location, start, limit)) + elif self.next_token_ == "-": + start = glyph + self.expect_symbol_("-") + limit = self.expect_glyph_() + glyphs.add_range( + start, limit, + self.make_glyph_range_(location, start, limit)) + else: + glyphs.append(glyph) + elif self.next_token_type_ is Lexer.CID: + glyph = self.expect_glyph_() + if self.next_token_ == "-": + range_location = self.cur_token_location_ + range_start = self.cur_token_ + self.expect_symbol_("-") + range_end = self.expect_cid_() + glyphs.add_cid_range(range_start, range_end, + self.make_cid_range_(range_location, + range_start, range_end)) + else: + glyphs.append("cid%05d" % self.cur_token_) + elif self.next_token_type_ is Lexer.GLYPHCLASS: + self.advance_lexer_() + gc = self.glyphclasses_.resolve(self.cur_token_) + if gc is None: + raise FeatureLibError( + "Unknown glyph class @%s" % self.cur_token_, + self.cur_token_location_) + # fix bug don't output class definition, just the name. + if isinstance(gc, self.ast.MarkClass): + gcn = self.ast.MarkClassName(gc, location=self.cur_token_location_) + else: + gcn = self.ast.GlyphClassName(gc, location=self.cur_token_location_) + glyphs.add_class(gcn) + else: + raise FeatureLibError( + "Expected glyph name, glyph range, " + "or glyph class reference. Found %s" % self.next_token_, + self.next_token_location_) + self.expect_symbol_("]") + return glyphs + + def parseIfClass(self): + location = self.cur_token_location_ + self.expect_symbol_("(") + if self.next_token_type_ is Lexer.GLYPHCLASS: + self.advance_lexer_() + def ifClassTest(): + gc = self.glyphclasses_.resolve(self.cur_token_) + return gc is not None and len(gc.glyphSet()) + block = self.ast.IfBlock(ifClassTest, 'ifclass', '@'+self.cur_token_, location=location) + self.expect_symbol_(")") + import inspect # oh this is so ugly! + calledby = inspect.stack()[2][3] # called through lambda since extension + if calledby == 'parse_block_': + self.parse_subblock_(block, False) + else: + self.parse_statements_block_(block) + return block + else: + raise FeatureLibError("Syntax error missing glyphclass", location) + + def parseIfInfo(self): + location = self.cur_token_location_ + self.expect_symbol_("(") + name = self.expect_name_() + self.expect_symbol_(",") + reg = self.expect_string_() + self.expect_symbol_(")") + def ifInfoTest(): + s = self.fontinfo.get(name, "") + return re.search(reg, s) + block = self.ast.IfBlock(ifInfoTest, 'ifinfo', '{}, "{}"'.format(name, reg), location=location) + import inspect # oh this is so ugly! Instead caller should pass in context + calledby = inspect.stack()[2][3] # called through a lambda since extension + if calledby == 'parse_block_': + self.parse_subblock_(block, False) + else: + self.parse_statements_block_(block) + return block + + def parseKernPairsStatement_(self): + location = self.cur_token_location_ + res = self.ast.KernPairsStatement(self.kerninfo, location) + return res + + def parse_statements_block_(self, block): + self.expect_symbol_("{") + statements = block.statements + while self.next_token_ != "}" or self.cur_comments_: + self.advance_lexer_(comments=True) + if self.cur_token_type_ is Lexer.COMMENT: + statements.append( + self.ast.Comment(self.cur_token_, + location=self.cur_token_location_)) + elif self.is_cur_keyword_("include"): + statements.append(self.parse_include_()) + elif self.cur_token_type_ is Lexer.GLYPHCLASS: + statements.append(self.parse_glyphclass_definition_()) + elif self.is_cur_keyword_(("anon", "anonymous")): + statements.append(self.parse_anonymous_()) + elif self.is_cur_keyword_("anchorDef"): + statements.append(self.parse_anchordef_()) + elif self.is_cur_keyword_("languagesystem"): + statements.append(self.parse_languagesystem_()) + elif self.is_cur_keyword_("lookup"): + statements.append(self.parse_lookup_(vertical=False)) + elif self.is_cur_keyword_("markClass"): + statements.append(self.parse_markClass_()) + elif self.is_cur_keyword_("feature"): + statements.append(self.parse_feature_block_()) + elif self.is_cur_keyword_("table"): + statements.append(self.parse_table_()) + elif self.is_cur_keyword_("valueRecordDef"): + statements.append( + self.parse_valuerecord_definition_(vertical=False)) + elif self.cur_token_type_ is Lexer.NAME and self.cur_token_ in self.extensions: + statements.append(self.extensions[self.cur_token_](self)) + elif self.cur_token_type_ is Lexer.SYMBOL and self.cur_token_ == ";": + continue + else: + raise FeatureLibError( + "Expected feature, languagesystem, lookup, markClass, " + "table, or glyph class definition, got {} \"{}\"".format(self.cur_token_type_, self.cur_token_), + self.cur_token_location_) + + self.expect_symbol_("}") + # self.expect_symbol_(";") # can't have }; since tokens are space separated + + def parse_subblock_(self, block, vertical, stylisticset=False, + size_feature=None, cv_feature=None): + self.expect_symbol_("{") + for symtab in self.symbol_tables_: + symtab.enter_scope() + + statements = block.statements + while self.next_token_ != "}" or self.cur_comments_: + self.advance_lexer_(comments=True) + if self.cur_token_type_ is Lexer.COMMENT: + statements.append(self.ast.Comment( + self.cur_token_, location=self.cur_token_location_)) + elif self.cur_token_type_ is Lexer.GLYPHCLASS: + statements.append(self.parse_glyphclass_definition_()) + elif self.is_cur_keyword_("anchorDef"): + statements.append(self.parse_anchordef_()) + elif self.is_cur_keyword_({"enum", "enumerate"}): + statements.append(self.parse_enumerate_(vertical=vertical)) + elif self.is_cur_keyword_("feature"): + statements.append(self.parse_feature_reference_()) + elif self.is_cur_keyword_("ignore"): + statements.append(self.parse_ignore_()) + elif self.is_cur_keyword_("language"): + statements.append(self.parse_language_()) + elif self.is_cur_keyword_("lookup"): + statements.append(self.parse_lookup_(vertical)) + elif self.is_cur_keyword_("lookupflag"): + statements.append(self.parse_lookupflag_()) + elif self.is_cur_keyword_("markClass"): + statements.append(self.parse_markClass_()) + elif self.is_cur_keyword_({"pos", "position"}): + statements.append( + self.parse_position_(enumerated=False, vertical=vertical)) + elif self.is_cur_keyword_("script"): + statements.append(self.parse_script_()) + elif (self.is_cur_keyword_({"sub", "substitute", + "rsub", "reversesub"})): + statements.append(self.parse_substitute_()) + elif self.is_cur_keyword_("subtable"): + statements.append(self.parse_subtable_()) + elif self.is_cur_keyword_("valueRecordDef"): + statements.append(self.parse_valuerecord_definition_(vertical)) + elif stylisticset and self.is_cur_keyword_("featureNames"): + statements.append(self.parse_featureNames_(stylisticset)) + elif cv_feature and self.is_cur_keyword_("cvParameters"): + statements.append(self.parse_cvParameters_(cv_feature)) + elif size_feature and self.is_cur_keyword_("parameters"): + statements.append(self.parse_size_parameters_()) + elif size_feature and self.is_cur_keyword_("sizemenuname"): + statements.append(self.parse_size_menuname_()) + elif self.cur_token_type_ is Lexer.NAME and self.cur_token_ in self.extensions: + statements.append(self.extensions[self.cur_token_](self)) + elif self.cur_token_ == ";": + continue + else: + raise FeatureLibError( + "Expected glyph class definition or statement: got {} {}".format(self.cur_token_type_, self.cur_token_), + self.cur_token_location_) + + self.expect_symbol_("}") + for symtab in self.symbol_tables_: + symtab.exit_scope() + + def collect_block_(self): + self.expect_symbol_("{") + tokens = [(self.cur_token_type_, self.cur_token_)] + count = 1 + while count > 0: + self.advance_lexer_() + if self.cur_token_ == "{": + count += 1 + elif self.cur_token_ == "}": + count -= 1 + tokens.append((self.cur_token_type_, self.cur_token_)) + return tokens + + def parseDoStatement_(self): + location = self.cur_token_location_ + substatements = [] + ifs = [] + while True: + self.advance_lexer_() + if self.is_cur_keyword_("forlet"): + substatements.append(self.parseDoForLet_()) + elif self.is_cur_keyword_("forgroup") or self.is_cur_keyword_("for"): + substatements.append(self.parseDoFor_()) + elif self.is_cur_keyword_("let"): + substatements.append(self.parseDoLet_()) + elif self.is_cur_keyword_("if"): + ifs.append(self.parseDoIf_()) + elif self.cur_token_ == '{': + self.back_lexer_() + ifs.append(self.parseEmptyIf_()) + break + elif self.cur_token_type_ == Lexer.COMMENT: + continue + else: + self.back_lexer_() + break + res = self.ast.Block() + lex = self.lexer_.lexers_[-1] + for s in self.DoIterateValues_(substatements): + for i in ifs: + (_, v) = next(i.items(s)) + if v: + lex.scope = s + #import pdb; pdb.set_trace() + lex.pushstack(('tokens', i.block[:])) + self.advance_lexer_() + self.advance_lexer_() + try: + import inspect # oh this is so ugly! + calledby = inspect.stack()[2][3] # called through lambda since extension + if calledby == 'parse_block_': + self.parse_subblock_(res, False) + else: + self.parse_statements_block_(res) + except Exception as e: + logging.warning("In do context: " + str(s) + " lexer: " + repr(lex) + " at: " + str((self.cur_token_, self.next_token_))) + raise + return res + + def DoIterateValues_(self, substatements): + def updated(d, *a, **kw): + d.update(*a, **kw) + return d + results = [{}] + #import pdb; pdb.set_trace() + for s in substatements: + newresults = [] + for x in results: + for r in s.items(x): + c = x.copy() + c.update(r) + newresults.append(c) + results = newresults + for r in results: + yield r + + def parseDoFor_(self): + location = self.cur_token_location_ + self.advance_lexer_() + if self.cur_token_type_ is Lexer.NAME: + name = self.cur_token_ + else: + raise FeatureLibError("Bad name in do for statement", location) + self.expect_symbol_("=") + glyphs = self.parse_glyphclass_(True) + self.expect_symbol_(";") + res = self.ast.DoForSubStatement(name, glyphs, location=location) + return res + + def parseLetish_(self, callback): + # import pdb; pdb.set_trace() + location = self.cur_token_location_ + self.advance_lexer_() + names = [] + while self.cur_token_type_ == Lexer.NAME: + names.append(self.cur_token_) + if self.next_token_type_ is Lexer.SYMBOL: + if self.next_token_ == ",": + self.advance_lexer_() + elif self.next_token_ == "=": + break + self.advance_lexer_() + else: + raise FeatureLibError("Expected '=', found '%s'" % self.cur_token_, + self.cur_token_location_) + lex = self.lexer_.lexers_[-1] + lex.scan_over_(Lexer.CHAR_WHITESPACE_) + start = lex.pos_ + lex.scan_until_(";") + expr = lex.text_[start:lex.pos_] + self.advance_lexer_() + self.expect_symbol_(";") + return callback(names, expr, self, location=location) + + def parseDoLet_(self): + return self.parseLetish_(self.ast.DoLetSubStatement) + + def parseDoForLet_(self): + return self.parseLetish_(self.ast.DoForLetSubStatement) + + def parseDoIf_(self): + location = self.cur_token_location_ + lex = self.lexer_.lexers_[-1] + start = lex.pos_ + lex.scan_until_(";") + expr = self.next_token_ + " " + lex.text_[start:lex.pos_] + self.advance_lexer_() + self.expect_symbol_(";") + block = self.collect_block_() + keep = (self.next_token_type_, self.next_token_) + block = [keep] + block + [keep] + return self.ast.DoIfSubStatement(expr, self, block, location=location) + + def parseEmptyIf_(self): + location = self.cur_token_location_ + lex = self.lexer_.lexers_[-1] + start = lex.pos_ + expr = "True" + block = self.collect_block_() + keep = (self.next_token_type_, self.next_token_) + block = [keep] + block + [keep] + return self.ast.DoIfSubStatement(expr, self, block, location=location) + + def parseDefStatement_(self): + lex = self.lexer_.lexers_[-1] + start = lex.pos_ + lex.scan_until_("{") + fname = self.next_token_ + fsig = fname + lex.text_[start:lex.pos_].strip() + tag = re.escape(fname) + _, content, location = lex.scan_anonymous_block(tag) + self.advance_lexer_() + start = lex.pos_ + lex.scan_until_(";") + endtag = lex.text_[start:lex.pos_].strip() + assert(fname == endtag) + self.advance_lexer_() + self.advance_lexer_() + funcstr = "def " + fsig + ":\n" + content + if astx.safeeval(funcstr): + exec(funcstr, self.fns) + return self.ast.Comment("# def " + fname) diff --git a/src/silfont/ftml.py b/src/silfont/ftml.py new file mode 100644 index 0000000..cd0a59f --- /dev/null +++ b/src/silfont/ftml.py @@ -0,0 +1,433 @@ +#!/usr/bin/env python3 +'Classes and functions for use handling FTML objects in pysilfont scripts' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2016 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from xml.etree import ElementTree as ET +from fontTools import ttLib +import re +from xml.sax.saxutils import quoteattr +import silfont.core +import silfont.etutil as ETU + +# Regular expression for parsing font name +fontspec = re.compile(r"""^ # beginning of string + (?P<rest>[A-Za-z ]+?) # Font Family Name + \s*(?P<bold>Bold)? # Bold + \s*(?P<italic>Italic)? # Italic + \s*(?P<regular>Regular)? # Regular + $""", re.VERBOSE) # end of string + +class Fxml(ETU.ETelement) : + def __init__(self, file = None, xmlstring = None, testgrouplabel = None, logger = None, params = None) : + self.logger = logger if logger is not None else silfont.core.loggerobj() + self.params = params if params is not None else silfont.core.parameters() + self.parseerrors=None + if not exactlyoneof(file, xmlstring, testgrouplabel) : self.logger.log("Must supply exactly one of file, xmlstring and testgrouplabel","X") + + if testgrouplabel : # Create minimal valid ftml + xmlstring = '<ftml version="1.0"><head></head><testgroup label=' + quoteattr(testgrouplabel) +'></testgroup></ftml>' + + if file and not hasattr(file, 'read') : self.logger.log("'file' is not a file object", "X") # ET.parse would also work on file name, but other code assumes file object + + try : + if file : + self.element = ET.parse(file).getroot() + else : + self.element = ET.fromstring(xmlstring) + except Exception as e : + self.logger.log("Error parsing FTML input: " + str(e), "S") + + super(Fxml,self).__init__(self.element) + + self.version = getattrib(self.element,"version") + if self.version != "1.0" : self.logger.log("ftml items must have a version of 1.0", "S") + + self.process_subelements(( + ("head", "head" , Fhead, True, False), + ("testgroup", "testgroups", Ftestgroup, True, True )), + offspec = False) + + self.stylesheet = {} + if file : # If reading from file, look to see if a stylesheet is present in xml processing instructions + file.seek(0) # Have to re-read file since ElementTree does not support processing instructions + for line in file : + if line[0:2] == "<?" : + line = line.strip()[:-2] # Strip white space and removing training ?> + parts = line.split(" ") + if parts[0] == "<?xml-stylesheet" : + for part in parts[1:] : + (name,value) = part.split("=") + self.stylesheet[name] = value[1:-1] # Strip quotes + break + else : + break + + self.filename = file if file else None + + if self.parseerrors: + self.logger.log("Errors parsing ftml element:","E") + for error in self.parseerrors : self.logger.log(" " + error,"E") + self.logger.log("Invalid FTML", "S") + + def save(self, file) : + self.outxmlstr="" + element = self.create_element() + etw = ETU.ETWriter(element, inlineelem = ["em"]) + self.outxmlstr = etw.serialize_xml() + file.write(self.outxmlstr) + + def create_element(self) : # Create a new Elementtree element based on current object contents + element = ET.Element('ftml', version = str(self.version)) + if self.stylesheet : # Create dummy .pi attribute for style sheet processing instruction + pi = "xml-stylesheet" + for attrib in sorted(self.stylesheet) : pi = pi + ' ' + attrib + '="' + self.stylesheet[attrib] + '"' ## Spec is not clear about what order attributes should be in + element.attrib['.pi'] = pi + element.append(self.head.create_element()) + for testgroup in self.testgroups : element.append(testgroup.create_element()) + return element + +class Fhead(ETU.ETelement) : + def __init__(self, parent, element) : + self.parent = parent + self.logger = parent.logger + super(Fhead,self).__init__(element) + + self.process_subelements(( + ("comment", "comment", None, False, False), + ("fontscale", "fontscale", None, False, False), + ("fontsrc", "fontsrc", Ffontsrc, False, True), + ("styles", "styles", ETU.ETelement, False, False ), # Initially just basic elements; Fstyles created below + ("title", "title", None, False, False), + ("widths", "widths", _Fwidth, False, False)), + offspec = True) + + if self.fontscale is not None : self.fontscale = int(self.fontscale) + if self.styles is not None : + styles = {} + for styleelem in self.styles["style"] : + style = Fstyle(self, element = styleelem) + styles[style.name] = style + if style.parseerrors: + name = "" if style.name is None else style.name + self.parseerrors.append("Errors parsing style element: " + name) + for error in style.parseerrors : self.parseerrors.append(" " + error) + self.styles = styles + if self.widths is not None : self.widths = self.widths.widthsdict # Convert _Fwidths object into dict + + self.elements = dict(self._contents) # Dictionary of all elements, particularly for handling non-standard elements + + def findstyle(self, name = None, feats = None, lang = None) : + if self.styles is not None: + for s in self.styles : + style = self.styles[s] + if style.feats == feats and style.lang == lang : + if name is None or name == style.name : return style # if name is supplied it must match + return None + + def addstyle(self, name, feats = None, lang = None) : # Return style if it exists otherwise create new style with newname + s = self.findstyle(name, feats, lang) + if s is None : + if self.styles is None: + self.styles = {} + if name in self.styles : self.logger.log("Adding duplicate style name " + name, "X") + s = Fstyle(self, name = name, feats = feats, lang = lang) + self.styles[name] = s + return s + + def create_element(self) : + element = ET.Element('head') + # Add in-spec sub-elements in alphabetic order + if self.comment : x = ET.SubElement(element, 'comment') ; x.text = self.comment + if self.fontscale : x = ET.SubElement(element, 'fontscale') ; x.text = str(self.fontscale) + if isinstance(self.fontsrc, list): + # Allow multiple fontsrc + for fontsrc in self.fontsrc: + element.append(fontsrc.create_element()) + elif self.fontsrc is not None: + element.append(self.fontsrc.create_element()) + if self.styles : + x = ET.SubElement(element, 'styles') + for style in sorted(self.styles) : x.append(self.styles[style].create_element()) + if self.title : y = ET.SubElement(element, 'title') ; y.text = self.title + if not self.widths is None : + x = ET.SubElement(element, 'widths') + for width in sorted(self.widths) : + if self.widths[width] is not None: x.set(width, self.widths[width]) + + # Add any non-spec elements + for el in sorted(self.elements) : + if el not in ("comment", "fontscale", "fontsrc", "styles", "title", "widths") : + for elem in self.elements[el] : element.append(elem) + + return element + +class Ffontsrc(ETU.ETelement) : + # This library only supports a single font in the fontsrc as recommended by the FTML spec + # Currently it only supports simple url() and local() values + + def __init__(self, parent, element = None, text = None, label=None) : + self.parent = parent + self.logger = parent.logger + self.parseerrors = [] + + if not exactlyoneof(element, text) : self.logger.log("Must supply exactly one of element and text","X") + + try: + (txt, url, local) = parsefontsrc(text, allowplain=True) if text else parsefontsrc(element.text) + except ValueError as e : + txt = text if text else element.text + self.parseerrors.append(str(e) + ": " + txt) + else : + if text : element = ET.Element("fontsrc") ; element.text = txt + if label : element.set('label', label) + super(Ffontsrc,self).__init__(element) + self.process_attributes(( + ("label", "label", False),), + others=False) + self.text = txt + self.url = url + self.local = local + if self.local : # Parse font name to find if bold, italic etc + results = re.match(fontspec, self.local) ## Does not cope with -, eg Gentium-Bold. Should it?" + self.fontfamily = results.group('rest') + self.bold = results.group('bold') != None + self.italic = results.group('italic') != None + else : + self.fontfamily = None # If details are needed call getweights() + + def addfontinfo(self) : # set fontfamily, bold and italic by looking inside font + (ff, bold, italic) = getfontinfo(self.url) + self.fontfamily = ff + self.bold = bold + self.italic = italic + + def create_element(self) : + element = ET.Element("fontsrc") + element.text = self.text + if self.label : element.set("label", self.label) + return element + +class Fstyle(ETU.ETelement) : + def __init__(self, parent, element = None, name = None, feats = None, lang = None) : + self.parent = parent + self.logger = parent.logger + if element is not None : + if name or feats or lang : parent.logger("Can't supply element and other parameters", "X") + else : + if name is None : self.logger.log("Must supply element or name to Fstyle", "X") + element = self.element = ET.Element("style", name = name) + if feats is not None : + if type(feats) is dict : feats = self.dict_to_string(feats) + element.set('feats',feats) + if lang is not None : element.set('lang', lang) + super(Fstyle,self).__init__(element) + + self.process_attributes(( + ("feats", "feats", False), + ("lang", "lang", False), + ("name", "name", True)), + others = False) + + if type(self.feats) is str : self.feats = self.string_to_dict(self.feats) + + def string_to_dict(self, string) : # Split string on ',', then add to dict splitting on " " and removing quotes + dict={} + for f in string.split(','): + f = f.strip() + m = re.match(r'''(?P<quote>['"])(\w{4})(?P=quote)\s+(\d+|on|off)$''', f) + if m: + dict[m.group(2)] = m.group(3) + else: + self.logger.log(f'Invalid feature syntax "{f}"', 'E') + return dict + + def dict_to_string(self, dict) : + str="" + for name in sorted(dict) : + if dict[name] is not None : str += "'" + name + "' " + dict[name] + ", " + str = str[0:-2] # remove final ", " + return str + + def create_element(self) : + element = ET.Element("style", name = self.name) + if self.feats : element.set("feats", self.dict_to_string(self.feats)) + if self.lang : element.set("lang", self.lang) + return element + + +class _Fwidth(ETU.ETelement) : # Only used temporarily whilst parsing xml + def __init__(self, parent, element) : + super(_Fwidth,self).__init__(element) + self.parent = parent + self.logger = parent.logger + + self.process_attributes(( + ("comment", "comment", False), + ("label", "label", False), + ("string", "string", False), + ("stylename", "stylename", False), + ("table", "table", False)), + others = False) + self.widthsdict = { + "comment": self.comment, + "label": self.label, + "string": self.string, + "stylename": self.stylename, + "table": self.table} + +class Ftestgroup(ETU.ETelement) : + def __init__(self, parent, element = None, label = None) : + self.parent = parent + self.logger = parent.logger + if not exactlyoneof(element, label) : self.logger.log("Must supply exactly one of element and label","X") + + if label : element = ET.Element("testgroup", label = label) + + super(Ftestgroup,self).__init__(element) + + self.subgroup = True if type(parent) is Ftestgroup else False + self.process_attributes(( + ("background", "background", False), + ("label", "label", True)), + others = False) + self.process_subelements(( + ("comment", "comment", None, False, False), + ("test", "tests", Ftest, False, True), + ("testgroup", "testgroups", Ftestgroup, False, True)), + offspec = False) + if self.subgroup and self.testgroups != [] : parent.parseerrors.append("Only one level of testgroup nesting permitted") + + # Merge any sub-testgroups into tests + if self.testgroups != [] : + tests = [] + tg = list(self.testgroups) # Want to preserve original list + for elem in self.element : + if elem.tag == "test": + tests.append(self.tests.pop(0)) + elif elem.tag == "testgroup" : + tests.append(tg.pop(0)) + self.tests = tests + + def create_element(self) : + element = ET.Element("testgroup") + if self.background : element.set("background", self.background) + element.set("label", self.label) + if self.comment : x = ET.SubElement(element, 'comment') ; x.text = self.comment + for test in self.tests : element.append(test.create_element()) + return element + +class Ftest(ETU.ETelement) : + def __init__(self, parent, element = None, label = None, string = None) : + self.parent = parent + self.logger = parent.logger + if not exactlyoneof(element, (label, string)) : self.logger.log("Must supply exactly one of element and label/string","X") + + if label : + element = ET.Element("test", label = label) + x = ET.SubElement(element,"string") ; x.text = string + + super(Ftest,self).__init__(element) + + self.process_attributes(( + ("background", "background", False), + ("label", "label", True), + ("rtl", "rtl", False), + ("stylename", "stylename", False)), + others = False) + + self.process_subelements(( + ("comment", "comment", None, False, False), + ("string", "string", _Fstring, True, False)), + offspec = False) + + self.string = self.string.string # self.string initially a temporary _Fstring element + + def str(self, noems = False) : # Return formatted version of string + string = self.string + if noems : + string = string.replace("<em>","") + string = string.replace("</em>","") + return string ## Other formatting options to be added as needed cf ftml2odt + + def create_element(self) : + element = ET.Element("test") + if self.background : element.set("background", self.background) + element.set("label", self.label) + if self.rtl : element.set("rtl", self.rtl) + if self.stylename : element.set("stylename", self.stylename) + if self.comment : x = ET.SubElement(element, "comment") ; x.text = self.comment + x = ET.SubElement(element, "string") ; x.text = self.string + + return element + +class _Fstring(ETU.ETelement) : # Only used temporarily whilst parsing xml + def __init__(self, parent, element = None) : + self.parent = parent + self.logger = parent.logger + super(_Fstring,self).__init__(element) + self.process_subelements((("em", "em", ETU.ETelement,False, True),), offspec = False) + # Need to build text of string to include <em> subelements + self.string = element.text if element.text else "" + for em in self.em : + self.string += "<em>{}</em>{}".format(em.element.text, em.element.tail) + +def getattrib(element,attrib) : + return element.attrib[attrib] if attrib in element.attrib else None + +def exactlyoneof( *args ) : # Check one and only one of args is not None + + last = args[-1] # Check if last argument is a tuple - in which case + if type(last) is tuple : # either all or none of list must be None + for test in last[1:] : + if (test is None) != (last[0] == None) : return False + args = list(args) # Convert to list so last val can be changed + args[-1] = last[0] # Now valid to test on any item in tuple + + one = False + for test in args : + if test is not None : + if one : return False # already have found one not None + one = True + if one : return True + return False + +def parsefontsrc(text, allowplain = False) : # Check fontsrc text is valid and return normalised text, url and local values + ''' - if multiple (fallback) fonts are specified, just process the first one + - just handles simple url() or local() formats + - if allowplain is set, allows text without url() or local() and decides which based on "." in text ''' + text = text.split(",")[0] # If multiple (fallback) fonts are specified, just process the first one + #if allowplain and not re.match(r"^(url|local)[(][^)]+[)]",text) : # Allow for text without url() or local() form + if allowplain and not "(" in text : # Allow for text without url() or local() form + plain = True + if "." in text : + type = "url" + else : + type = "local" + else : + type = text.split("(")[0] + if type == "url" : + text = text.split("(")[1][:-1].strip() + elif type == "local" : + text = text.split("(")[1][:-1].strip() + else : raise ValueError("Invalid fontsrc string") + if type == "url" : + return ("url("+text+")", text, None) + else : + return ("local("+text+")", None , text) + + return (text,url,local) + +def getfontinfo(filename) : # peek inside the font for the name, weight, style + f = ttLib.TTFont(filename) + # take name from name table, NameID 1, platform ID 3, Encoding ID 1 (possible fallback platformID 1, EncodingID =0) + n = f['name'] # name table from font + fontname = n.getName(1,3,1).toUnicode() # nameID 1 = Font Family name + # take bold and italic info from OS/2 table, fsSelection bits 0 and 5 + o = f['OS/2'] # OS/2 table + italic = (o.fsSelection & 1) > 0 + bold = (o.fsSelection & 32) > 0 + return (fontname, bold, italic) + diff --git a/src/silfont/ftml_builder.py b/src/silfont/ftml_builder.py new file mode 100644 index 0000000..c6dc01b --- /dev/null +++ b/src/silfont/ftml_builder.py @@ -0,0 +1,750 @@ +#!/usr/bin/env python3 +"""classes and functions for building ftml tests from glyph_data.csv and UFO""" +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Bob Hallissy' + +from silfont.ftml import Fxml, Ftestgroup, Ftest, Ffontsrc +from palaso.unicode.ucd import get_ucd +from itertools import product +import re +import collections.abc + +# This module comprises two related functionalities: +# 1. The FTML object which acts as a staging object for ftml test data. The methods of this class +# permit a gradual build-up of an ftml file, e.g., +# +# startTestGroup(...) +# setFeatures(...) +# addToTest(...) +# addToTest(...) +# clearFeatures(...) +# setLang(...) +# addToTest(...) +# closeTestGroup(...) +# ... +# writeFile(...) +# +# The module is clever enough, for example, to automatically close a test when changing features, languages or direction. +# +# 2. The FTMLBuilder object which reads and processes glyph_data.csv and provides assistance in iterating over +# the characters, features, and languages that should be supported by the font, e.g.: +# +# ftml.startTestGroup('Encoded characters') +# for uid in sorted(builder.uids()): +# if uid < 32: continue +# c = builder.char(uid) +# for featlist in builder.permuteFeatures(uids=[uid]): +# ftml.setFeatures(featlist) +# builder.render([uid], ftml) +# ftml.clearFeatures() +# for langID in sorted(c.langs): +# ftml.setLang(langID) +# builder.render([uid], ftml) +# ftml.clearLang() +# +# See examples/psfgenftml.py for ideas + +class FTML(object): + """a staging class for collecting ftml content and finally writing the xml""" + + # Assumes no nesting of test groups + + def __init__(self, title, logger, comment = None, fontsrc = None, fontlabel = None, fontscale = None, + widths = None, rendercheck = True, xslfn = None, defaultrtl = False): + self.logger = logger + # Initialize an Fxml object + fxml = Fxml(testgrouplabel = "dummy") + fxml.stylesheet = {'type': 'text/xsl', 'href': xslfn if xslfn is not None else 'ftml.xsl'} + fxml.head.title = title + fxml.head.comment = comment + if isinstance(fontsrc, (tuple, list)): + # Allow multiple fontsrc + fxml.head.fontsrc = [Ffontsrc(fxml.head, text=fontsrc, + label=fontlabel[i] if fontlabel is not None and i < len(fontlabel) else None) + for i, fontsrc in enumerate(fontsrc)] + elif fontsrc: + fxml.head.fontsrc = Ffontsrc(fxml.head, text=fontsrc, label=fontlabel) + + if fontscale: fxml.head.fontscale = int(fontscale) + if widths: fxml.head.widths = widths + fxml.testgroups.pop() # Remove dummy test group + # Save object + self._fxml = fxml + # Initialize state + self._curTest = None + self.closeTestGroup() + self.defaultRTL = defaultrtl + # Add first testgroup if requested + if rendercheck: + self.startTestGroup("Rendering Check", background="#F0F0F0") + self.addToTest(None, "RenderingUnknown", "check", rtl = False) + self.closeTest() + self.closeTestGroup() + + _colorMap = { + 'aqua': '#00ffff', + 'black': '#000000', + 'blue': '#0000ff', + 'fuchsia': '#ff00ff', + 'green': '#008000', + 'grey': '#808080', + 'lime': '#00ff00', + 'maroon': '#800000', + 'navy': '#000080', + 'olive': '#808000', + 'purple': '#800080', + 'red': '#ff0000', + 'silver': '#c0c0c0', + 'teal': '#008080', + 'white': '#ffffff', + 'yellow': '#ffff00', + 'orange': '#ffa500' + } + + @staticmethod + def _getColor(color): + if color is None or len(color) == 0: + return None + color = color.lower() + if color in FTML._colorMap: + return FTML._colorMap[color] + if re.match(r'#[0-9a-f]{6}$', color): + return color + self.logger.log(f'Color "{color}" not understood; ignored', 'W') + return None + + def closeTest(self, comment = None): + if self._curTest: + if comment is not None: + self._curTest.comment = comment + if self._curColor: + self._curTest.background = self._curColor + self._curTest = None + self._lastUID = None + self._lastRTL = None + + def addToTest(self, uid, s = "", label = None, comment = None, rtl = None): + if rtl is None: rtl = self.defaultRTL + if (self._lastUID and uid and uid not in range(self._lastUID, self._lastUID + 2))\ + or (self._lastRTL is not None and rtl != self._lastRTL): + self.closeTest() + self._lastUID = uid + self._lastRTL = rtl + if self._curTestGroup is None: + # Create a new Ftestgroup + self.startTestGroup("Group") + if self._curTest is None: + # Create a new Ftest + if label is None: + label = "U+{0:04X}".format(uid) if uid is not None else "test" + test = Ftest(self._curTestGroup, label = label, string = '') + if comment: + test.comment = comment + if rtl: test.rtl = "True" + # Construct stylename and add style if needed: + x = ['{}_{}'.format(t,v) for t,v in self._curFeatures.items()] if self._curFeatures else [] + if self._curLang: + x.insert(0,self._curLang) + if len(x): + test.stylename = '_'.join(x) + self._fxml.head.addstyle(test.stylename, feats = self._curFeatures, lang = self._curLang) + # Append to current test group + self._curTestGroup.tests.append(test) + self._curTest = test + if len(self._curTest.string): self._curTest.string += ' ' + # Special hack until we get to python3 with full unicode support + self._curTest.string += ''.join([ c if ord(c) < 128 else '\\u{0:06X}'.format(ord(c)) for c in s ]) + # self._curTest.string += s + + def setFeatures(self, features): + # features can be None or a list; list elements can be: + # None + # a feature setting in the form [tag,value] + if features is None: + return self.clearFeatures() + features = [x for x in features if x] + if len(features) == 0: + return self.clearFeatures() + features = dict(features) # Convert to a dictionary -- this is what we'll keep. + if features != self._curFeatures: + self.closeTest() + self._curFeatures = features + + def clearFeatures(self): + if self._curFeatures is not None: + self.closeTest() + self._curFeatures = None + + def setLang(self, langID): + if langID != self._curLang: + self.closeTest(); + self._curLang = langID + + def clearLang(self): + if self._curLang: + self.closeTest() + self._curLang = None + + def setBackground(self, color): + color = self._getColor(color) + if color != self._curColor: + self.closeTest() + self._curColor = color + + def clearBackground(self): + if self._curColor is not None: + self.closeTest() + self._curColor = None + + def closeTestGroup(self): + self.closeTest() + self._curTestGroup = None + self._curFeatures = None + self._curLang = None + self._curColor = None + + def startTestGroup(self, label, background = None): + if self._curTestGroup is not None: + if label == self._curTestGroup.label: + return + self.closeTestGroup() + # Add new test group + self._curTestGroup = Ftestgroup(self._fxml, label = label) + background = self._getColor(background) + if background is not None: + self._curTestGroup.background = background + + # append to root test groups + self._fxml.testgroups.append(self._curTestGroup) + + def writeFile(self, output): + self.closeTestGroup() + self._fxml.save(output) + + +class Feature(object): + """abstraction of a feature""" + + def __init__(self, tag): + self.tag = tag + self.default = 0 + self.maxval = 1 + self._tvlist = None + + def __getattr__(self,name): + if name == "tvlist": + # tvlist is a list of all possible tag,value pairs (except the default but including None) for this feature + # This attribute shouldn't be needed until all the possible feature value are known, + # therefore we'll generate this the first time we need it and save it + if self._tvlist is None: + self._tvlist = [ None ] + for v in range (0, self.maxval+1): + if v != self.default: + self._tvlist.append( [self.tag, str(v)]) + return self._tvlist + + +class FChar(object): + """abstraction of an encoded glyph in the font""" + + def __init__(self, uids, basename, logger): + self.logger = logger + # uids can be a singleton integer or, for multiple-encoded glyphs, some kind of sequence of integers + if isinstance(uids,collections.abc.Sequence): + uids1 = uids + else: + uids1 = (uids,) + # test each uid to make sure valid; remove if not. + uids2=[] + self.general = "unknown" + for uid in uids1: + try: + gc = get_ucd(uid,'gc') + if self.general == "unknown": + self.general = gc + uids2.append(uid) + except (TypeError, IndexError): + self.logger.log(f'Invalid USV "{uid}" -- ignored.', 'E') + continue + except KeyError: + self.logger.log('USV %04X not defined; no properties known' % uid, 'W') + # make sure there's at least one left + assert len(uids2) > 0, f'No valid USVs found in {repr(uids)}' + self._uids = tuple(uids2) + self.basename = basename + self.feats = set() # feat tags that affect this char + self.langs = set() # lang tags that affect this char + self.aps = set() + self.altnames = {} # alternate glyph names. + # the above is a dict keyed by either: + # lang tag e.g., 'ur', or + # feat tag and value, e.g., 'cv24=3' + # and returns a the glyphname for that alternate. + # Additional info from UFO: + self.takesMarks = self.isMark = self.isBase = self.notInUFO = False + + # Most callers don't need to support or or care about multiple-encoded glyphs, so we + # support the old .uid attribute by returning the first (I guess we consider it primary) uid. + def __getattr__(self,name): + if name == 'uids': + return self._uids + elif name == 'uid': + return self._uids[0] + else: + raise AttributeError + + # the static method FTMLBuilder.checkGlyph is likely preferred + # but leave this instance method for backwards compatibility + def checkGlyph(self, gname, font, apRE): + # glean info from UFO if glyph is present + if gname in font.deflayer: + self.notInUFO = False + for a in font.deflayer[gname]['anchor'] : + name = a.element.get('name') + if apRE.match(name) is None: + continue + self.aps.add(name) + if name.startswith("_") : + self.isMark = True + else: + self.takesMarks = True + self.isBase = self.takesMarks and not self.isMark + else: + self.notInUFO = True + + +class FSpecial(object): + """abstraction of a ligature or other interesting sequence""" + + # Similar to FChar but takes a uid list rather than a single uid + def __init__(self, uids, basename, logger): + self.logger = logger + self.uids = uids + self.basename = basename + # a couple of properties based on the first uid: + try: + self.general = get_ucd(uids[0],'gc') + except KeyError: + self.logger.log('USV %04X not defined; no properties known' % uids[0], 'W') + self.feats = set() # feat tags that affect this char + self.aps = set() + self.langs = set() # lang tags that affect this char + self.altnames = {} # alternate glyph names. + self.takesMarks = self.isMark = self.isBase = self.notInUFO = False + +class FTMLBuilder(object): + """glyph_data and UFO processing for building FTML""" + + def __init__(self, logger, incsv = None, fontcode = None, font = None, langs = None, rtlenable = False, ap = None ): + self.logger = logger + self.rtlEnable = rtlenable + + # Default diacritic base: + self.diacBase = 0x25CC + + # Default joinBefore and joinAfter sequence + self.joinBefore = '\u200D' # put before a sequence to force joining shape; def = zwj + self.joinAfter = '\u200D' # put after a sequence to force joining shape; def = zwj + + # Dict mapping tag to Feature + self.features = {} + + # Set of all languages seen + if langs is not None: + # Use a list so we keep the order (assuming caller wouldn't give us dups + self.allLangs = list(re.split(r'\s*[\s,]\s*', langs)) # Allow comma- or space-separated tags + self._langsComplete = True # We have all the lang tags desired + else: + # use a set because the langtags are going to dribble in and be repeated. + self.allLangs = set() + self._langsComplete = False # Add lang_tags from glyph_data + + # Be able to find chars and specials: + self._charFromUID = {} + self._charFromBasename = {} + self._specialFromUIDs = {} + self._specialFromBasename = {} + + # list of USVs that are in the CSV but whose glyphs are not in the UFO + self.uidsMissingFromUFO = set() + + # DummyUSV (see charAuto()) + self.curDummyUSV = 0x100000 # Supplemental Private Use Area B + + # Compile --ap parameter + if ap is None: + ap = "." + try: + self.apRE = re.compile(ap) + except re.error as e: + logger.log("--ap parameter '{}' doesn't compile as regular expression: {}".format(ap, e), "S") + + if incsv is not None: + self.readGlyphData(incsv, fontcode, font) + + def addChar(self, uids, basename): + # Add an FChar + # assume parameters are OK: + c = FChar(uids, basename, self.logger) + # fatal error if the basename or any of uids have already been seen + fatal = False + for uid in c.uids: + if uid in self._charFromUID: + self.logger.log('Attempt to add duplicate USV %04X' % uid, 'E') + fatal = True + self._charFromUID[uid] = c + if basename in self._charFromBasename: + self.logger.log('Attempt to add duplicate basename %s' % basename, 'E') + fatal = True + self._charFromBasename[basename] = c + if fatal: + self.logger.log('Cannot continue due to previous errors', 'S') + return c + + def uids(self): + """ returns list of uids in glyph_data """ + return self._charFromUID.keys() + + def char(self, x): + """ finds an FChar based either basename or uid; + generates KeyError if not found.""" + return self._charFromBasename[x] if isinstance(x, str) else self._charFromUID[x] + + def charAuto(self, x): + """ Like char() but will issue a warning and add a dummy """ + try: + return self._charFromBasename[x] if isinstance(x, str) else self._charFromUID[x] + except KeyError: + # Issue error message and create dummy Char object for this character + if isinstance(x, str): + self.logger.log(f'Glyph "{x}" isn\'t in glyph_data.csv - adding dummy', 'E') + while self.curDummyUSV in self._charFromUID: + self.curDummyUSV += 1 + c = self.addChar(self.curDummyUSV, x) + else: + self.logger.log(f'Char U+{x:04x} isn\'t in glyph_data.csv - adding dummy', 'E') + c = self.addChar(x, f'U+{x:04x}') + return c + + def addSpecial(self, uids, basename): + # Add an FSpecial: + # fatal error if basename has already been seen: + if basename in self._specialFromBasename: + self.logger.log('Attempt to add duplicate basename %s' % basename, 'S') + c = FSpecial(uids, basename, self.logger) + # remember it: + self._specialFromUIDs[tuple(uids)] = c + self._specialFromBasename[basename] = c + return c + + def specials(self): + """returns a list of the basenames of specials""" + return self._specialFromBasename.keys() + + def special(self, x): + """ finds an FSpecial based either basename or uid sequence; + generates KeyError if not found.""" + return self._specialFromBasename[x] if isinstance(x, str) else self._specialFromUIDs[tuple(x)] + + def _csvWarning(self, msg, exception = None): + m = "glyph_data line {1}: {0}".format(msg, self.incsv.line_num) + if exception is not None: + m += '; ' + str(exception) + self.logger.log(m, 'W') + + def readGlyphData(self, incsv, fontcode = None, font = None): + # Remember csv file for other methods: + self.incsv = incsv + + # Validate fontcode, if provided + if fontcode is not None: + whichfont = fontcode.strip().lower() + if len(whichfont) != 1: + self.logger.log('fontcode must be a single letter', 'S') + else: + whichfont = None + + # Get headings from csvfile: + fl = incsv.firstline + if fl is None: self.logger.log("Empty input file", "S") + # required columns: + try: + nameCol = fl.index('glyph_name'); + usvCol = fl.index('USV') + except ValueError as e: + self.logger.log('Missing csv input field: ' + str(e), 'S') + except Exception as e: + self.logger.log('Error reading csv input field: ' + str(e), 'S') + # optional columns: + # If -f specified, make sure we have the fonts column + if whichfont is not None: + if 'Fonts' not in fl: self.logger.log('-f requires "Fonts" column in glyph_data', 'S') + fontsCol = fl.index('Fonts') + # Allow for projects that use only production glyph names (ps_name same as glyph_name) + psCol = fl.index('ps_name') if 'ps_name' in fl else nameCol + # Allow for projects that have no feature and/or lang-specific behaviors + featCol = fl.index('Feat') if 'Feat' in fl else None + bcp47Col = fl.index('bcp47tags') if 'bcp47tags' in fl else None + + next(incsv.reader, None) # Skip first line with headers + + # RE that matches names of glyphs we don't care about + namesToSkipRE = re.compile('^(?:[._].*|null|cr|nonmarkingreturn|tab|glyph_name)$',re.IGNORECASE) + + # RE that matches things like 'cv23' or 'cv23=4' or 'cv23=2,3' + featRE = re.compile('^(\w{2,4})(?:=([\d,]+))?$') + + # RE that matches USV sequences for ligatures + ligatureRE = re.compile('^[0-9A-Fa-f]{4,6}(?:_[0-9A-Fa-f]{4,6})+$') + + # RE that matches space-separated USV sequences + USVsRE = re.compile('^[0-9A-Fa-f]{4,6}(?:\s+[0-9A-Fa-f]{4,6})*$') + + # keep track of glyph names we've seen to detect duplicates + namesSeen = set() + psnamesSeen = set() + + # OK, process all records in glyph_data + for line in incsv: + gname = line[nameCol].strip() + + # things to ignore: + if namesToSkipRE.match(gname): + continue + if whichfont is not None and line[fontsCol] != '*' and line[fontsCol].lower().find(whichfont) < 0: + continue + if len(gname) == 0: + self._csvWarning('empty glyph name in glyph_data; ignored') + continue + if gname.startswith('#'): + continue + if gname in namesSeen: + self._csvWarning('glyph name %s previously seen in glyph_data; ignored' % gname) + continue + + psname = line[psCol].strip() or gname # If psname absent, working name will be production name + if psname in psnamesSeen: + self._csvWarning('psname %s previously seen; ignored' % psname) + continue + namesSeen.add(gname) + psnamesSeen.add(psname) + + # compute basename-- the glyph name without extensions: + basename = gname.split('.',1)[0] + + # Process USV(s) + # could be empty string, a single USV, space-separated list of USVs for multiple encoding, + # or underscore-connected USVs indicating ligatures. + + usvs = line[usvCol].strip() + if len(usvs) == 0: + # Empty USV field, unencoded glyph + usvs = () + elif USVsRE.match(usvs): + # space-separated hex values: + usvs = usvs.split() + isLigature = False + elif ligatureRE.match(usvs): + # '_' separated hex values (ligatures) + usvs = usvs.split('_') + isLigature = True + else: + self._csvWarning(f"invalid USV field '{usvs}'; ignored") + usvs = () + uids = [int(x, 16) for x in usvs] + + if len(uids) == 0: + # Handle unencoded glyphs + uids = None # Prevents using this record to set default feature values + if basename in self._charFromBasename: + c = self._charFromBasename[basename] + # Check for additional AP info + c.checkGlyph(gname, font, self.apRE) + elif basename in self._specialFromBasename: + c = self._specialFromBasename[basename] + else: + self._csvWarning('unencoded variant %s found before encoded glyph' % gname) + c = None + elif isLigature: + # Handle ligatures + c = self.addSpecial(uids, basename) + uids = None # Prevents using this record to set default feature values (TODO: Research this) + else: + # Handle simple encoded glyphs (could be multiple uids!) + # Create character object + c = self.addChar(uids, basename) + if font is not None: + # Examine APs to determine if this character takes marks: + c.checkGlyph(gname, font, self.apRE) + if c.notInUFO: + self.uidsMissingFromUFO.update(uids) + + if featCol is not None: + feats = line[featCol].strip() + if len(feats) > 0 and not(feats.startswith('#')): + feats = feats.split(';') + for feat in feats: + m = featRE.match(feat) + if m is None: + self._csvWarning('incorrectly formed feature specification "%s"; ignored' % feat) + else: + # find/create structure for this feature: + tag = m.group(1) + try: + feature = self.features[tag] + except KeyError: + feature = Feature(tag) + self.features[tag] = feature + # if values supplied, collect default and maximum values for this feature: + if m.group(2) is not None: + vals = [int(i) for i in m.group(2).split(',')] + if len(vals) > 0: + if uids is not None: + feature.default = vals[0] + elif len(feats) == 1: # TODO: This seems like wrong test. + for v in vals: + # remember the glyph name for this feature/value combination: + feat = '{}={}'.format(tag,v) + if c is not None and feat not in c.altnames: + c.altnames[feat] = gname + vals.append(feature.maxval) + feature.maxval = max(vals) + if c is not None: + # Record that this feature affects this character: + c.feats.add(tag) + else: + self._csvWarning('untestable feature "%s" : no known USV' % tag) + + if bcp47Col is not None: + bcp47 = line[bcp47Col].strip() + if len(bcp47) > 0 and not(bcp47.startswith('#')): + if c is not None: + for tag in re.split(r'\s*[\s,]\s*', bcp47): # Allow comma- or space-separated tags + c.langs.add(tag) # lang-tags mentioned for this character + if not self._langsComplete: + self.allLangs.add(tag) # keep track of all possible lang-tags + else: + self._csvWarning('untestable langs: no known USV') + + # We're finally done, but if allLangs is a set, let's order it (for lack of anything better) and make a list: + if not self._langsComplete: + self.allLangs = list(sorted(self.allLangs)) + + def permuteFeatures(self, uids = None, feats = None): + """ returns an iterator that provides all combinations of feature/value pairs, for a list of uids and/or a specific list of feature tags""" + feats = set(feats) if feats is not None else set() + if uids is not None: + for uid in uids: + if uid in self._charFromUID: + feats.update(self._charFromUID[uid].feats) + l = [self.features[tag].tvlist for tag in sorted(feats)] + return product(*l) + + @staticmethod + def checkGlyph(obj, gname, font, apRE): + # glean info from UFO if glyph is present + if gname in font.deflayer: + obj.notInUFO = False + for a in font.deflayer[gname]['anchor']: + name = a.element.get('name') + if apRE.match(name) is None: + continue + obj.aps.add(name) + if name.startswith("_"): + obj.isMark = True + else: + obj.takesMarks = True + obj.isBase = obj.takesMarks and not obj.isMark + else: + obj.notInUFO = True + + @staticmethod + def matchMarkBase(c_mark, c_base): + """ test whether an _AP on c_mark matches an AP on c_base """ + for apM in c_mark.aps: + if apM.startswith("_"): + ap = apM[1:] + for apB in c_base.aps: + if apB == ap: + return True + return False + + def render(self, uids, ftml, keyUID = 0, addBreaks = True, rtl = None, dualJoinMode = 3, label = None, comment = None): + """ general purpose (but not required) function to generate ftml for a character sequence """ + if len(uids) == 0: + return + # Make a copy so we don't affect caller + uids = list(uids) + # Remember first uid and original length for later + startUID = uids[0] + uidLen = len(uids) + # if keyUID wasn't supplied, use startUID + if keyUID == 0: keyUID = startUID + if label is None: + # Construct label from uids: + label = '\n'.join(['U+{0:04X}'.format(u) for u in uids]) + if comment is None: + # Construct comment from glyph names: + comment = ' '.join([self._charFromUID[u].basename for u in uids]) + # see if uid list includes a mirrored char + hasMirrored = bool(len([x for x in uids if get_ucd(x,'Bidi_M')])) + # Analyze first and last joining char + joiningChars = [x for x in uids if get_ucd(x, 'jt') != 'T'] + if len(joiningChars): + # If first or last non-TRANSPARENT char is a joining char, then we need to emit examples with zwj + # Assumes any non-TRANSPARENT char that is bc != L must be a rtl character of some sort + uid = joiningChars[0] + zwjBefore = (get_ucd(uid,'jt') == 'D' + or (get_ucd(uid,'bc') == 'L' and get_ucd(uid,'jt') == 'L') + or (get_ucd(uid,'bc') != 'L' and get_ucd(uid,'jt') == 'R')) + uid = joiningChars[-1] + zwjAfter = (get_ucd(uid,'jt') == 'D' + or (get_ucd(uid,'bc') == 'L' and get_ucd(uid,'jt') == 'R') + or (get_ucd(uid,'bc') != 'L' and get_ucd(uid,'jt') == 'L')) + else: + zwjBefore = zwjAfter = False + if get_ucd(startUID,'gc') == 'Mn': + # First char is a NSM... prefix a suitable base + uids.insert(0, self.diacBase) + zwjBefore = False # No longer any need to put zwj before + elif get_ucd(startUID, 'WSpace'): + # First char is whitespace -- prefix with baseline brackets: + uids.insert(0, 0xF130) + lastNonMark = [x for x in uids if get_ucd(x,'gc') != 'Mn'][-1] + if get_ucd(lastNonMark, 'WSpace'): + # Last non-mark is whitespace -- append baseline brackets: + uids.append(0xF131) + s = ''.join([chr(uid) for uid in uids]) + if zwjBefore or zwjAfter: + # Show contextual forms: + # Start with isolate + t = u'{0} '.format(s) + if zwjBefore and zwjAfter: + # For sequences that show dual-joining behavior, what we show depends on dualJoinMode: + if dualJoinMode & 1: + # show initial, medial, final separated by space: + t += u'{0}{2} {1}{0}{2} {1}{0} '.format(s, self.joinBefore, self.joinAfter) + if dualJoinMode & 2: + # show 3 joined forms in sequence: + t += u'{0}{0}{0} '.format(s) + elif zwjAfter: + t += u'{0}{1} '.format(s, self.joinAfter) + elif zwjBefore: + t += u'{1}{0} '.format(s, self.joinBefore) + if addBreaks: ftml.closeTest() + ftml.addToTest(keyUID, t, label = label, comment = comment, rtl = rtl) + if addBreaks: ftml.closeTest() + elif hasMirrored and self.rtlEnable: + # Contains mirrored and rtl enabled: + if addBreaks: ftml.closeTest() + ftml.addToTest(keyUID, u'{0} LTR: \u202A{0}\u202C RTL: \u202B{0}\u202C'.format(s), label = label, comment = comment, rtl = rtl) + if addBreaks: ftml.closeTest() + # elif is LRE, RLE, PDF + # elif is LRI, RLI, FSI, PDI + elif uidLen > 1: + ftml.addToTest(keyUID, s , label = label, comment = comment, rtl = rtl) + else: + ftml.addToTest(keyUID, s , comment = comment, rtl = rtl) + diff --git a/src/silfont/gfr.py b/src/silfont/gfr.py new file mode 100644 index 0000000..0e32169 --- /dev/null +++ b/src/silfont/gfr.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +__doc__ = '''General classes and functions for use with SIL's github fonts repository, github.com/silnrsi/fonts''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2022 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +import os, json, io +import urllib.request as urllib2 +from zipfile import ZipFile +from silfont.util import prettyjson +from silfont.core import splitfn, loggerobj +from collections import OrderedDict +from fontTools.ttLib import TTFont + +familyfields = OrderedDict([ + ("familyid", {"opt": True, "manifest": False}), # req for families.json but not for base files; handled in code + ("fallback", {"opt": True, "manifest": False}), + ("family", {"opt": False, "manifest": True}), + ("altfamily", {"opt": True, "manifest": False}), + ("siteurl", {"opt": True, "manifest": False}), + ("packageurl", {"opt": True, "manifest": False}), + ("ziproot", {"opt": True, "manifest": False}), + ("files", {"opt": True, "manifest": True}), + ("defaults", {"opt": True, "manifest": True}), + ("version", {"opt": True, "manifest": True}), + ("status", {"opt": True, "manifest": False}), + ("license", {"opt": True, "manifest": False}), + ("distributable", {"opt": False, "manifest": False}), + ("source", {"opt": True, "manifest": False}), + ("googlefonts", {"opt": True, "manifest": False}), + ("features", {"opt": True, "manifest": False}) + ]) + +filefields = OrderedDict([ + ("altfamily", {"opt": True, "manifest": True, "mopt": True}), + ("url", {"opt": True, "manifest": False}), + ("flourl", {"opt": True, "manifest": False}), + ("packagepath", {"opt": True, "manifest": True}), + ("zippath", {"opt": True, "manifest": False}), + ("axes", {"opt": False, "manifest": True}) + ]) + +defaultsfields = OrderedDict([ + ("ttf", {"opt": True, "manifest": True}), + ("woff", {"opt": True, "manifest": True, "mopt": True}), + ("woff2", {"opt": True, "manifest": True, "mopt": True}) + ]) + +class _familydata(object): + """Family data key for use with families.json, font manifests and base files + """ + def __init__(self, id=None, data=None, filename = None, type="f", logger=None): + # Initial input can be a dictionary (data) in which case id nneds to be set + # or it can be read from a file (containing just one family record), in which case id is taken from the file + # Type can be f, b or m for families, base or manifest + # With f, this would be for just a single entry from a families.json file + self.id = id + self.data = data if data else {} + self.filename = filename + self.type = type + self.logger = logger if logger else loggerobj() + + def fieldscheck(self, data, validfields, reqfields, logprefix, valid, logs): + for key in data: # Check all keys have valid names + if key not in validfields: + logs.append((f'{logprefix}: Invalid field "{key}"', 'W')) + valid = False + continue + # Are required fields present + for key in reqfields: + if key not in data: + logs.append((f'{logprefix}: Required field "{key}" missing', 'W')) + valid = False + continue + return (valid, logs) + + def validate(self): + global familyfields, filefields, defaultsfields + logs = [] + valid = True + if self.type == "m": + validfields = reqfields = [key for key in familyfields if familyfields[key]["manifest"]] + else: + validfields = list(familyfields) + reqfields = [key for key in familyfields if not familyfields[key]["opt"]] + if self.type == "f": + reqfields = reqfields + ["familyid"] + else: # Must be b + validfields = validfields + ["hosturl", "filesroot"] + + (valid, logs) = self.fieldscheck(self.data, validfields, reqfields, "Main", valid, logs) + # Now check sub-fields + if "files" in self.data: + fdata = self.data["files"] + if self.type == "m": + validfields = [key for key in filefields if filefields[key]["manifest"]] + reqfields = [key for key in filefields if filefields[key]["manifest"] and not ("mopt" in filefields[key] and filefields[key]["mopt"])] + else: + validfields = list(filefields) + reqfields = [key for key in filefields if not filefields[key]["opt"]] + # Now need to check values for each record in files + for filen in fdata: + frecord = fdata[filen] + (valid, logs) = self.fieldscheck(frecord, validfields, reqfields, "Files: " + filen, valid, logs) + if "axes" in frecord: # (Will already have been reported above if axes is missing!) + adata = frecord["axes"] + avalidfields = [key for key in adata if len(key) == 4] + areqfields = ["wght", "ital"] if self.type == "m" else [] + (valid, logs) = self.fieldscheck(adata, avalidfields, areqfields, "Files, axes: " + filen, valid, logs) + if "defaults" in self.data: + ddata = self.data["defaults"] + if self.type == "m": + validfields = [key for key in defaultsfields if defaultsfields[key]["manifest"]] + reqfields = [key for key in defaultsfields if defaultsfields[key]["manifest"] and not ("mopt" in defaultsfields[key] and defaultsfields[key]["mopt"])] + else: + validfields = list(defaultsfields) + reqfields = [key for key in defaultsfields if not defaultsfields[key]["opt"]] + (valid, logs) = self.fieldscheck(ddata, validfields, reqfields, "Defaults:", valid, logs) + return (valid, logs) + + def read(self, filename=None): # Read data from file (not for families.json) + if filename: self.filename = filename + with open(self.filename) as infile: + try: + filedata = json.load(infile) + except Exception as e: + self.logger.log(f'Error opening {infile}: {e}', 'S') + if len(filedata) != 1: + self.logger.log(f'Files must contain just one record; {self.filename} has {len(filedata)}') + self.id = list(filedata.keys())[0] + self.data = filedata[self.id] + + def write(self, filename=None): # Write data to a file (not for families.json) + if filename is None: filename = self.filename + self.logger.log(f'Writing to {filename}', 'P') + filedata = {self.id: self.data} + with open(filename, "w", encoding="utf-8") as outf: + outf.write(prettyjson(filedata, oneliners=["files"])) + +class gfr_manifest(_familydata): + # + def __init__(self, id=None, data=None, filename = None, logger=None): + super(gfr_manifest, self).__init__(id=id, data=data, filename=filename, type="m", logger=logger) + + def validate(self, version=None, filename=None, checkfiles=True): + # Validate the manifest. + # If version is supplied, check that that matches the version in the manifest + # If self.filename not already set, the filename of the manifest must be supplied + (valid, logs) = super(gfr_manifest, self).validate() # Field name validation based on _familydata validation + + if filename is None: filename = self.filename + data = self.data + + if "files" in data and checkfiles: + files = data["files"] + mfilelist = {x: files[x]["packagepath"] for x in files} + + # Check files that are on disk match the manifest files + (path, base, ext) = splitfn(filename) + fontexts = ['.ttf', '.woff', '.woff2'] + dfilelist = {} + for dirname, subdirs, filenames in os.walk(path): + for filen in filenames: + (base, ext) = os.path.splitext(filen) + if ext in fontexts: + dfilelist[filen] = (os.path.relpath(os.path.join(dirname, filen), start=path).replace('\\', '/')) + + if mfilelist == dfilelist: + logs.append(('Files OK', 'I')) + else: + valid = False + logs.append(('Files on disk and in manifest do not match.', 'W')) + logs.append(('Files on disk:', 'I')) + for filen in sorted(dfilelist): + logs.append((f' {dfilelist[filen]}', 'I')) + logs.append(('Files in manifest:', 'I')) + for filen in sorted(mfilelist): + logs.append((f' {mfilelist[filen]}', 'I')) + + if "defaults" in data: + defaults = data["defaults"] + # Check defaults exist + allthere = True + for default in defaults: + if defaults[default] not in mfilelist: allthere = False + + if allthere: + logs.append(('Defaults OK', 'I')) + else: + valid = False + logs.append(('At least one default missing', 'W')) + + if version: + if "version" in data: + mversion = data["version"] + if version == mversion: + logs.append(('Versions OK', 'I')) + else: + valid = False + logs.append((f'Version mismatch: {version} supplied and {mversion} in manifest', "W")) + + return (valid, logs) + +class gfr_base(_familydata): + # + def __init__(self, id=None, data=None, filename = None, logger=None): + super(gfr_base, self).__init__(id=id, data=data, filename=filename, type="b", logger=logger) + +class gfr_family(object): # For families.json. + # + def __init__(self, data=None, filename=None, logger=None): + self.filename = filename + self.logger = logger if logger else loggerobj() + self.familyrecords = {} + if data is not None: self.familyrecords = data + + def validate(self, familyid=None): + allvalid = True + alllogs = [] + if familyid: + record = self.familyrecords[familyid] + (allvalid, alllogs) = record.validate() + else: + for familyid in self.familyrecords: + record = self.familyrecords[familyid] + (valid, logs) = record.validate() + if not valid: + allvalid = False + alllogs.append(logs) + return allvalid, alllogs + + def write(self, filename=None): # Write data to a file + if filename is None: filename = self.filename + self.logger.log(f'Writing to {filename}', "P") + with open(filename, "w", encoding="utf-8") as outf: + outf.write(prettyjson(self.familyrecords, oneliners=["files"])) + +def setpaths(logger): # Check that the script is being run from the root of the repository and set standard paths + repopath = os.path.abspath(os.path.curdir) + # Do cursory checks that this is the root of the fonts repo + if repopath[-5:] != "fonts" or not os.path.isdir(os.path.join(repopath, "fonts/sil")): + logger.log("GFR scripts must be run from the root of the fonts repo", "S") + # Set up standard paths for scripts to use + silpath = os.path.join(repopath, "fonts/sil") + otherpath = os.path.join(repopath, "fonts/other") + basespath = os.path.join(repopath, "basefiles") + if not os.path.isdir(basespath): os.makedirs(basespath) + return repopath, silpath, otherpath, basespath + +def getttfdata(ttf, logger): # Extract data from a ttf + + try: + font = TTFont(ttf) + except Exception as e: + logger.log(f'Error opening {ttf}: {e}', 'S') + + name = font['name'] + os2 = font['OS/2'] + post = font['post'] + + values = {} + + name16 = name.getName(nameID=16, platformID=3, platEncID=1, langID=0x409) + + values["family"] = str(name16) if name16 else str(name.getName(nameID=1, platformID=3, platEncID=1, langID=0x409)) + values["subfamily"] = str(name.getName(nameID=2, platformID=3, platEncID=1, langID=0x409)) + values["version"] = str(name.getName(nameID=5, platformID=3, platEncID=1, langID=0x409))[8:] # Remove "Version " from the front + values["wght"] = os2.usWeightClass + values["ital"] = 0 if getattr(post, "italicAngle") == 0 else 1 + + return values +def getziproot(url, ttfpath): + req = urllib2.Request(url=url, headers={'User-Agent': 'Mozilla/4.0 (compatible; httpget)'}) + try: + reqdat = urllib2.urlopen(req) + except Exception as e: + return (None, f'{url} not valid: {str(e)}') + zipdat = reqdat.read() + zipinfile = io.BytesIO(initial_bytes=zipdat) + try: + zipf = ZipFile(zipinfile) + except Exception as e: + return (None, f'{url} is not a valid zip file') + for zf in zipf.namelist(): + if zf.endswith(ttfpath): # found a font, assume we want it + ziproot = zf[:-len(ttfpath) - 1] # strip trailing / + return (ziproot, "") + else: + return (None, f"Can't find {ttfpath} in {url}") diff --git a/src/silfont/harfbuzz.py b/src/silfont/harfbuzz.py new file mode 100644 index 0000000..75728ca --- /dev/null +++ b/src/silfont/harfbuzz.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +'Harfbuzz support for fonttools' + +import gi +gi.require_version('HarfBuzz', '0.0') +from gi.repository import HarfBuzz as hb +from gi.repository import GLib + +class Glyph(object): + def __init__(self, gid, **kw): + self.gid = gid + for k,v in kw.items(): + setattr(self, k, v) + + def __repr__(self): + return "[{gid}@({offset[0]},{offset[1]})+({advance[0]},{advance[1]})]".format(**self.__dict__) + +def shape_text(f, text, features = [], lang=None, dir="", script="", shapers=""): + fontfile = f.reader.file + fontfile.seek(0, 0) + fontdata = fontfile.read() + blob = hb.glib_blob_create(GLib.Bytes.new(fontdata)) + face = hb.face_create(blob, 0) + del blob + font = hb.font_create(face) + upem = hb.face_get_upem(face) + del face + hb.font_set_scale(font, upem, upem) + hb.ot_font_set_funcs(font) + + buf = hb.buffer_create() + t = text.encode('utf-8') + hb.buffer_add_utf8(buf, t, 0, -1) + hb.buffer_guess_segment_properties(buf) + if dir: + hb.buffer_set_direction(buf, hb.direction_from_string(dir)) + if script: + hb.buffer_set_script(buf, hb.script_from_string(script)) + if lang: + hb.buffer_set_language(buf, hb.language_from_string(lang)) + + feats = [] + if len(features): + for feat_string in features: + if hb.feature_from_string(feat_string, -1, aFeats): + feats.append(aFeats) + if shapers: + hb.shape_full(font, buf, feats, shapers) + else: + hb.shape(font, buf, feats) + + num_glyphs = hb.buffer_get_length(buf) + info = hb.buffer_get_glyph_infos(buf) + pos = hb.buffer_get_glyph_positions(buf) + + glyphs = [] + for i in range(num_glyphs): + glyphs.append(Glyph(info[i].codepoint, cluster = info[i].cluster, + offset = (pos[i].x_offset, pos[i].y_offset), + advance = (pos[i].x_advance, pos[i].y_advance), + flags = info[i].mask)) + return glyphs + +if __name__ == '__main__': + import sys + from fontTools.ttLib import TTFont + font = sys.argv[1] + text = sys.argv[2] + f = TTFont(font) + glyphs = shape_text(f, text) + print(glyphs) diff --git a/src/silfont/ipython.py b/src/silfont/ipython.py new file mode 100644 index 0000000..77b702c --- /dev/null +++ b/src/silfont/ipython.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +'IPython support for fonttools' + +__all__ = ['displayGlyphs', 'loadFont', 'displayText', 'displayRaw'] + +from fontTools import ttLib +from fontTools.pens.basePen import BasePen +from fontTools.misc import arrayTools +from IPython.display import SVG, HTML +from defcon import Font +from ufo2ft import compileTTF + +class SVGPen(BasePen) : + + def __init__(self, glyphSet, scale=1.0) : + super(SVGPen, self).__init__(glyphSet); + self.__commands = [] + self.__scale = scale + + def __str__(self) : + return " ".join(self.__commands) + + def scale(self, pt) : + return ((pt[0] or 0) * self.__scale, (pt[1] or 0) * self.__scale) + + def _moveTo(self, pt): + self.__commands.append("M {0[0]} {0[1]}".format(self.scale(pt))) + + def _lineTo(self, pt): + self.__commands.append("L {0[0]} {0[1]}".format(self.scale(pt))) + + def _curveToOne(self, pt1, pt2, pt3) : + self.__commands.append("C {0[0]} {0[1]} {1[0]} {1[1]} {2[0]} {2[1]}".format(self.scale(pt1), self.scale(pt2), self.scale(pt3))) + + def _closePath(self) : + self.__commands.append("Z") + + def clear(self) : + self.__commands = [] + +def _svgheader(): + return '''<?xml version="1.0"?> +<svg xmlns="https://www.w3.org/2000/svg" xmlns:xlink="https://www.w3.org/1999/xlink" version="1.1"> +''' + +def _bbox(f, gnames, points, scale=1): + gset = f.glyphSet + bbox = (0, 0, 0, 0) + for i, gname in enumerate(gnames): + if hasattr(points, '__len__') and i == len(points): + points.append((bbox[2] / scale, 0)) + pt = points[i] if i < len(points) else (0, 0) + g = gset[gname]._glyph + if g is None or not hasattr(g, 'xMin') : + gbox = (0, 0, 0, 0) + else : + gbox = (g.xMin * scale, g.yMin * scale, g.xMax * scale, g.yMax * scale) + bbox = arrayTools.unionRect(bbox, arrayTools.offsetRect(gbox, pt[0] * scale, pt[1] * scale)) + return bbox + +glyphsetcount = 0 +def _defglyphs(f, gnames, scale=1): + global glyphsetcount + glyphsetcount += 1 + gset = f.glyphSet + p = SVGPen(gset, scale) + res = "<defs><g>\n" + for gname in sorted(set(gnames)): + res += '<symbol overflow="visible" id="{}_{}">\n'.format(gname, glyphsetcount) + g = gset[gname] + p.clear() + g.draw(p) + res += '<path style="stroke:none;" d="' + str(p) + '"/>\n</symbol>\n' + res += "</g></defs>\n" + return res + +def loadFont(fname): + if fname.lower().endswith(".ufo"): + ufo = Font(fname) + f = compileTTF(ufo) + else: + f = ttLib.TTFont(fname) + return f + +def displayGlyphs(f, gnames, points=None, scale=None): + if not hasattr(gnames, '__len__') or isinstance(gnames, basestring): + gnames = [gnames] + if not hasattr(points, '__len__'): + points = [] + if not hasattr(f, 'glyphSet'): + f.glyphSet = f.getGlyphSet() + res = _svgheader() + if points is None: + points = [] + bbox = _bbox(f, gnames, points, scale or 1) + maxh = 100. + height = bbox[3] - (bbox[1] if bbox[1] < 0 else 0) + if scale is None and height > maxh: + scale = maxh / height + bbox = [x * scale for x in bbox] + res += _defglyphs(f, gnames, scale) + res += '<g id="surface1" transform="matrix(1,0,0,-1,{},{})">\n'.format(-bbox[0], bbox[3]) + res += ' <rect x="{}" y="{}" width="{}" height="{}" style="fill:white;stroke:none"/>\n'.format( + bbox[0], bbox[1], bbox[2]-bbox[0], bbox[3]) + res += ' <g style="fill:black">\n' + for i, gname in enumerate(gnames): + pt = points[i] if i < len(points) else (0, 0) + res += ' <use xlink:href="#{0}_{3}" x="{1}" y="{2}"/>\n'.format(gname, pt[0] * scale, pt[1] * scale, glyphsetcount) + res += ' </g></g>\n</svg>\n' + return SVG(data=res) + #return res + +def displayText(f, text, features = [], lang=None, dir="", script="", shapers="", size=0): + import harfbuzz + glyphs = harfbuzz.shape_text(f, text, features, lang, dir, script, shapers) + gnames = [] + points = [] + x = 0 + y = 0 + for g in glyphs: + gnames.append(f.getGlyphName(g.gid)) + points.append((x+g.offset[0], y+g.offset[1])) + x += g.advance[0] + y += g.advance[1] + if size == 0: + scale = None + else: + upem = f['head'].unitsPerEm + scale = 4. * size / (upem * 3.) + return displayGlyphs(f, gnames, points, scale=scale) + +def displayRaw(text): + # res = "<html><body>"+text.encode('utf-8')+"</body></html>" + res = u"<html><body><p>"+text+u"</p></body></html>" + return HTML(data=res) diff --git a/src/silfont/scripts/__init__.py b/src/silfont/scripts/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/silfont/scripts/__init__.py diff --git a/src/silfont/scripts/psfaddanchors.py b/src/silfont/scripts/psfaddanchors.py new file mode 100644 index 0000000..3db2178 --- /dev/null +++ b/src/silfont/scripts/psfaddanchors.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +__doc__ = 'read anchor data from XML file and apply to UFO' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Rowe' + +from silfont.core import execute +from xml.etree import ElementTree as ET + +argspec = [ + ('ifont',{'help': 'Input UFO'}, {'type': 'infont'}), + ('ofont',{'help': 'Output UFO','nargs': '?' }, {'type': 'outfont'}), + ('-i','--anchorinfo',{'help': 'XML file with anchor data'}, {'type': 'infile', 'def': '_anc.xml'}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': '_anc.log'}), + ('-a','--analysis',{'help': 'Analysis only; no output font generated', 'action': 'store_true'},{}), + ('-d','--delete',{'help': 'Delete APs from a glyph before adding', 'action': 'store_true'}, {}), + # 'choices' for -r should correspond to infont.logger.loglevels.keys() + ('-r','--report',{'help': 'Set reporting level for log', 'type':str, 'choices':['X','S','E','P','W','I','V']},{}) + ] + +def doit(args) : + infont = args.ifont + if args.report: infont.logger.loglevel = args.report + glyphcount = 0 + + try: + for g in ET.parse(args.anchorinfo).getroot().findall('glyph'): ### + glyphcount += 1 + gname = g.get('PSName') + if gname not in infont.deflayer.keys(): + infont.logger.log("glyph element number " + str(glyphcount) + ": " + gname + " not in font, so skipping anchor data", "W") + continue + # anchors currently in font for this glyph + glyph = infont.deflayer[gname] + if args.delete: + glyph['anchor'].clear() + anchorsinfont = set([ ( a.element.get('name'), a.element.get('x'), a.element.get('y') ) for a in glyph['anchor']]) + # anchors in XML file to be added + anchorstoadd = set() + for p in g.findall('point'): + name = p.get('type') + x = p[0].get('x') # assume subelement location is first child + y = p[0].get('y') + if name and x and y: + anchorstoadd.add( (name, x, y) ) + else: + infont.logger.log("Incomplete information for anchor '" + name + "' for glyph " + gname, "E") + # compare sets + if anchorstoadd == anchorsinfont: + if len(anchorstoadd) > 0: + infont.logger.log("Anchors in file already in font for glyph " + gname + ": " + str(anchorstoadd), "V") + else: + infont.logger.log("No anchors in file or in font for glyph " + gname, "V") + else: + infont.logger.log("Anchors in file for glyph " + gname + ": " + str(anchorstoadd), "I") + infont.logger.log("Anchors in font for glyph " + gname + ": " + str(anchorsinfont), "I") + for name,x,y in anchorstoadd: + # if anchor being added exists in font already, delete it first + ancnames = [a.element.get('name') for a in glyph['anchor']] + infont.logger.log(str(ancnames), "V") ### + if name in ancnames: + infont.logger.log("removing anchor " + name + ", index " + str(ancnames.index(name)), "V") ### + glyph.remove('anchor', ancnames.index(name)) + infont.logger.log("adding anchor " + name + ": (" + x + ", " + y + ")", "V") ### + glyph.add('anchor', {'name': name, 'x': x, 'y': y}) + # If analysis only, return without writing output font + if args.analysis: return + # Return changed font and let execute() write it out + return infont + except ET.ParseError as mess: + infont.logger.log("Error parsing XML input file: " + str(mess), "S") + return # but really should terminate after logging Severe error above + +def cmd() : execute("UFO",doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfbuildcomp.py b/src/silfont/scripts/psfbuildcomp.py new file mode 100644 index 0000000..48aa5c6 --- /dev/null +++ b/src/silfont/scripts/psfbuildcomp.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +__doc__ = '''Read Composite Definitions and add glyphs to a UFO font''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Rowe' + +try: + xrange +except NameError: + xrange = range +from xml.etree import ElementTree as ET +import re +from silfont.core import execute +import silfont.ufo as ufo +from silfont.comp import CompGlyph +from silfont.etutil import ETWriter +from silfont.util import parsecolors + +argspec = [ + ('ifont',{'help': 'Input UFO'}, {'type': 'infont'}), + ('ofont',{'help': 'Output UFO','nargs': '?' }, {'type': 'outfont'}), + ('-i','--cdfile',{'help': 'Composite Definitions input file'}, {'type': 'infile', 'def': '_CD.txt'}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': '_CD.log'}), + ('-a','--analysis',{'help': 'Analysis only; no output font generated', 'action': 'store_true'},{}), + ('-c','--color',{'help': 'Color cells of generated glyphs', 'action': 'store_true'},{}), + ('--colors', {'help': 'Color(s) to use when marking generated glyphs'},{}), + ('-f','--force',{'help': 'Force overwrite of glyphs having outlines', 'action': 'store_true'},{}), + ('-n','--noflatten',{'help': 'Do not flatten component references', 'action': 'store_true'},{}), + ('--remove',{'help': 'a regex matching anchor names that should always be removed from composites'},{}), + ('--preserve', {'help': 'a regex matching anchor names that, if present in glyphs about to be replace, should not be overwritten'}, {}) + ] + +glyphlist = [] # accessed as global by recursive function addtolist() and main function doit() + +def doit(args): + global glyphlist + infont = args.ifont + logger = args.logger + params = infont.outparams + + removeRE = re.compile(args.remove) if args.remove else None + preserveRE = re.compile(args.preserve) if args.preserve else None + + colors = None + if args.color or args.colors: + colors = args.colors if args.colors else "g_blue,g_purple" + colors = parsecolors(colors, allowspecial=True) + invalid = False + for color in colors: + if color[0] is None: + invalid = True + logger.log(color[2], "E") + if len(colors) > 3: + logger.log("A maximum of three colors can be supplied: " + str(len(colors)) + " supplied", "E") + invalid = True + if invalid: logger.log("Re-run with valid colors", "S") + if len(colors) == 1: colors.append(colors[0]) + if len(colors) == 2: colors.append(colors[1]) + logstatuses = ("Glyph unchanged", "Glyph changed", "New glyph") + + ### temp section (these may someday be passed as optional parameters) + RemoveUsedAnchors = True + ### end of temp section + + cgobj = CompGlyph() + + for linenum, rawCDline in enumerate(args.cdfile): + CDline=rawCDline.strip() + if len(CDline) == 0 or CDline[0] == "#": continue + logger.log("Processing line " + str(linenum+1) + ": " + CDline,"I") + cgobj.CDline=CDline + try: + cgobj.parsefromCDline() + except ValueError as mess: + logger.log("Parsing error: " + str(mess), "E") + continue + g = cgobj.CDelement + + # Collect target glyph information and construct list of component glyphs + targetglyphname = g.get("PSName") + targetglyphunicode = g.get("UID") + glyphlist = [] # list of component glyphs + lsb = rsb = 0 + adv = None + for e in g: + if e.tag == 'note': pass + elif e.tag == 'property': pass # ignore mark info + elif e.tag == 'lsb': lsb = int(e.get('width')) + elif e.tag == 'rsb': rsb = int(e.get('width')) + elif e.tag == 'advance': adv = int(e.get('width')) + elif e.tag == 'base': + addtolist(e,None) + logger.log(str(glyphlist),"V") + + # find each component glyph and compute x,y position + xadvance = lsb + componentlist = [] + targetglyphanchors = {} # dictionary of {name: (xOffset,yOffset)} + for currglyph, prevglyph, baseAP, diacAP, shiftx, shifty in glyphlist: + # get current glyph and its anchor names from font + if currglyph not in infont.deflayer: + logger.log(currglyph + " not found in font", "E") + continue + cg = infont.deflayer[currglyph] + cganc = [x.element.get('name') for x in cg['anchor']] + diacAPx = diacAPy = 0 + baseAPx = baseAPy = 0 + if prevglyph is None: # this is new 'base' + xOffset = xadvance + yOffset = 0 + # Find advance width of currglyph and add to xadvance + if 'advance' in cg: + cgadvance = cg['advance'] + if cgadvance is not None and cgadvance.element.get('width') is not None: + xadvance += int(float(cgadvance.element.get('width'))) + else: # this is 'attach' + if diacAP is not None: # find diacritic Attachment Point in currglyph + if diacAP not in cganc: + logger.log("The AP '" + diacAP + "' does not exist on diacritic glyph " + currglyph, "E") + else: + i = cganc.index(diacAP) + diacAPx = int(float(cg['anchor'][i].element.get('x'))) + diacAPy = int(float(cg['anchor'][i].element.get('y'))) + else: + logger.log("No AP specified for diacritic " + currglyph, "E") + if baseAP is not None: # find base character Attachment Point in targetglyph + if baseAP not in targetglyphanchors.keys(): + logger.log("The AP '" + baseAP + "' does not exist on base glyph when building " + targetglyphname, "E") + else: + baseAPx = targetglyphanchors[baseAP][0] + baseAPy = targetglyphanchors[baseAP][1] + if RemoveUsedAnchors: + logger.log("Removing used anchor " + baseAP, "V") + del targetglyphanchors[baseAP] + xOffset = baseAPx - diacAPx + yOffset = baseAPy - diacAPy + + if shiftx is not None: xOffset += int(shiftx) + if shifty is not None: yOffset += int(shifty) + + componentdic = {'base': currglyph} + if xOffset != 0: componentdic['xOffset'] = str(xOffset) + if yOffset != 0: componentdic['yOffset'] = str(yOffset) + componentlist.append( componentdic ) + + # Move anchor information to targetglyphanchors + for a in cg['anchor']: + dic = a.element.attrib + thisanchorname = dic['name'] + if RemoveUsedAnchors and thisanchorname == diacAP: + logger.log("Skipping used anchor " + diacAP, "V") + continue # skip this anchor + # add anchor (adjusted for position in targetglyph) + targetglyphanchors[thisanchorname] = ( int( dic['x'] ) + xOffset, int( dic['y'] ) + yOffset ) + logger.log("Adding anchor " + thisanchorname + ": " + str(targetglyphanchors[thisanchorname]), "V") + logger.log(str(targetglyphanchors),"V") + + if adv is not None: + xadvance = adv ### if adv specified, then this advance value overrides calculated value + else: + xadvance += rsb ### adjust with rsb + + logger.log("Glyph: " + targetglyphname + ", " + str(targetglyphunicode) + ", " + str(xadvance), "V") + for c in componentlist: + logger.log(str(c), "V") + + # Flatten components unless -n set + if not args.noflatten: + newcomponentlist = [] + for compdic in componentlist: + c = compdic['base'] + x = compdic.get('xOffset') + y = compdic.get('yOffset') + # look up component glyph + g=infont.deflayer[c] + # check if it has only components (that is, no contours) in outline + if g['outline'] and g['outline'].components and not g['outline'].contours: + # for each component, get base, x1, y1 and create new entry with base, x+x1, y+y1 + for subcomp in g['outline'].components: + componentdic = subcomp.element.attrib.copy() + x1 = componentdic.pop('xOffset', 0) + y1 = componentdic.pop('yOffset', 0) + xOffset = addtwo(x, x1) + yOffset = addtwo(y, y1) + if xOffset != 0: componentdic['xOffset'] = str(xOffset) + if yOffset != 0: componentdic['yOffset'] = str(yOffset) + newcomponentlist.append( componentdic ) + else: + newcomponentlist.append( compdic ) + if componentlist == newcomponentlist: + logger.log("No changes to flatten components", "V") + else: + componentlist = newcomponentlist + logger.log("Components flattened", "V") + for c in componentlist: + logger.log(str(c), "V") + + # Check if this new glyph exists in the font already; if so, decide whether to replace, or issue warning + preservedAPs = set() + if targetglyphname in infont.deflayer.keys(): + logger.log("Target glyph, " + targetglyphname + ", already exists in font.", "V") + targetglyph = infont.deflayer[targetglyphname] + if targetglyph['outline'] and targetglyph['outline'].contours and not args.force: # don't replace glyph with contours, unless -f set + logger.log("Not replacing existing glyph, " + targetglyphname + ", because it has contours.", "W") + continue + else: + logger.log("Replacing information in existing glyph, " + targetglyphname, "I") + glyphstatus = "Replace" + # delete information from existing glyph + targetglyph.remove('outline') + targetglyph.remove('advance') + for i in xrange(len(targetglyph['anchor'])-1,-1,-1): + aname = targetglyph['anchor'][i].element.attrib['name'] + if preserveRE is not None and preserveRE.match(aname): + preservedAPs.add(aname) + logger.log("Preserving anchor " + aname, "V") + else: + targetglyph.remove('anchor',index=i) + else: + logger.log("Adding new glyph, " + targetglyphname, "I") + glyphstatus = "New" + # create glyph, using targetglyphname, targetglyphunicode + targetglyph = ufo.Uglif(layer=infont.deflayer, name=targetglyphname) + # actually add the glyph to the font + infont.deflayer.addGlyph(targetglyph) + + if xadvance != 0: targetglyph.add('advance',{'width': str(xadvance)} ) + if targetglyphunicode: # remove any existing unicode value(s) before adding unicode value + for i in xrange(len(targetglyph['unicode'])-1,-1,-1): + targetglyph.remove('unicode',index=i) + targetglyph.add('unicode',{'hex': targetglyphunicode} ) + targetglyph.add('outline') + # to the outline element, add a component element for every entry in componentlist + for compdic in componentlist: + comp = ufo.Ucomponent(targetglyph['outline'],ET.Element('component',compdic)) + targetglyph['outline'].appendobject(comp,'component') + # copy anchors to new glyph from targetglyphanchors which has format {'U': (500,1000), 'L': (500,0)} + for a in sorted(targetglyphanchors): + if removeRE is not None and removeRE.match(a): + logger.log("Skipping unwanted anchor " + a, "V") + continue # skip this anchor + if a not in preservedAPs: + targetglyph.add('anchor', {'name': a, 'x': str(targetglyphanchors[a][0]), 'y': str(targetglyphanchors[a][1])} ) + # mark glyphs as being generated by setting cell mark color if -c or --colors set + if colors: + # Need to see if the target glyph has changed. + if glyphstatus == "Replace": + # Need to recreate the xml element then normalize it for comparison with original + targetglyph["anchor"].sort(key=lambda anchor: anchor.element.get("name")) + targetglyph.rebuildET() + attribOrder = params['attribOrders']['glif'] if 'glif' in params['attribOrders'] else {} + if params["sortDicts"] or params["precision"] is not None: ufo.normETdata(targetglyph.etree, params, 'glif') + etw = ETWriter(targetglyph.etree, attributeOrder=attribOrder, indentIncr=params["indentIncr"], + indentFirst=params["indentFirst"], indentML=params["indentML"], precision=params["precision"], + floatAttribs=params["floatAttribs"], intAttribs=params["intAttribs"]) + newxml = etw.serialize_xml() + if newxml == targetglyph.inxmlstr: glyphstatus = 'Unchanged' + + x = 0 if glyphstatus == "Unchanged" else 1 if glyphstatus == "Replace" else 2 + + color = colors[x] + lib = targetglyph["lib"] + if color[0]: # Need to set actual color + if lib is None: targetglyph.add("lib") + targetglyph["lib"].setval("public.markColor", "string", color[0]) + logger.log(logstatuses[x] + " - setting markColor to " + color[2], "I") + elif x < 2: # No need to log for new glyphs + if color[1] == "none": # Remove existing color + if lib is not None and "public.markColor" in lib: lib.remove("public.markColor") + logger.log(logstatuses[x] + " - Removing existing markColor", "I") + else: + logger.log(logstatuses[x] + " - Leaving existing markColor (if any)", "I") + + # If analysis only, return without writing output font + if args.analysis: return + # Return changed font and let execute() write it out + return infont + +def addtolist(e, prevglyph): + """Given an element ('base' or 'attach') and the name of previous glyph, + add a tuple to the list of glyphs in this composite, including + "at" and "with" attachment point information, and x and y shift values + """ + global glyphlist + subelementlist = [] + thisglyphname = e.get('PSName') + atvalue = e.get("at") + withvalue = e.get("with") + shiftx = shifty = None + for se in e: + if se.tag == 'property': pass + elif se.tag == 'shift': + shiftx = se.get('x') + shifty = se.get('y') + elif se.tag == 'attach': + subelementlist.append( se ) + glyphlist.append( ( thisglyphname, prevglyph, atvalue, withvalue, shiftx, shifty ) ) + for se in subelementlist: + addtolist(se, thisglyphname) + +def addtwo(a1, a2): + """Take two items (string, number or None), convert to integer and return sum""" + b1 = int(a1) if a1 is not None else 0 + b2 = int(a2) if a2 is not None else 0 + return b1 + b2 + +def cmd() : execute("UFO",doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfbuildcompgc.py b/src/silfont/scripts/psfbuildcompgc.py new file mode 100644 index 0000000..e967b35 --- /dev/null +++ b/src/silfont/scripts/psfbuildcompgc.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +'''Uses the GlyphConstruction library to build composite glyphs.''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Victor Gaultney' + +from silfont.core import execute +from glyphConstruction import ParseGlyphConstructionListFromString, GlyphConstructionBuilder + +argspec = [ + ('ifont', {'help': 'Input font filename'}, {'type': 'infont'}), + ('ofont',{'help': 'Output font file','nargs': '?' }, {'type': 'outfont'}), + ('-i','--cdfile',{'help': 'Composite Definitions input file'}, {'type': 'infile', 'def': 'constructions.txt'}), + ('-l','--log',{'help': 'Set log file name'}, {'type': 'outfile', 'def': '_gc.log'})] + +def doit(args) : + font = args.ifont + logger = args.logger + + constructions = ParseGlyphConstructionListFromString(args.cdfile) + + for construction in constructions : + # Create a new constructed glyph object + try: + constructionGlyph = GlyphConstructionBuilder(construction, font) + except ValueError as e: + logger.log("Invalid CD line '" + construction + "' - " + str(e), "E") + else: + # Make a new glyph in target font with the new glyph name + glyph = font.newGlyph(constructionGlyph.name) + # Draw the constructed object onto the new glyph + # This is rather odd in how it works + constructionGlyph.draw(glyph.getPen()) + # Copy glyph metadata from constructed object + glyph.name = constructionGlyph.name + glyph.unicode = constructionGlyph.unicode + glyph.note = constructionGlyph.note + #glyph.markColor = constructionGlyph.mark + glyph.width = constructionGlyph.width + + return font + +def cmd() : execute("FP",doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfbuildfea.py b/src/silfont/scripts/psfbuildfea.py new file mode 100644 index 0000000..f659315 --- /dev/null +++ b/src/silfont/scripts/psfbuildfea.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +__doc__ = 'Build features.fea file into a ttf font' +# TODO: add conditional compilation, compare to fea, compile to ttf +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Martin Hosken' + +from fontTools.feaLib.builder import Builder +from fontTools import configLogger +from fontTools.ttLib import TTFont +from fontTools.ttLib.tables.otTables import lookupTypes +from fontTools.feaLib.lookupDebugInfo import LookupDebugInfo + +from silfont.core import execute + +class MyBuilder(Builder): + + def __init__(self, font, featurefile, lateSortLookups=False, fronts=None): + super(MyBuilder, self).__init__(font, featurefile) + self.lateSortLookups = lateSortLookups + self.fronts = fronts if fronts is not None else [] + + def buildLookups_(self, tag): + assert tag in ('GPOS', 'GSUB'), tag + countFeatureLookups = 0 + fronts = set([l for k, l in self.named_lookups_.items() if k in self.fronts]) + for bldr in self.lookups_: + bldr.lookup_index = None + if bldr.table == tag and getattr(bldr, '_feature', "") != "": + countFeatureLookups += 1 + lookups = [] + latelookups = [] + for bldr in self.lookups_: + if bldr.table != tag: + continue + if self.lateSortLookups and getattr(bldr, '_feature', "") == "": + if bldr in fronts: + latelookups.insert(0, bldr) + else: + latelookups.append(bldr) + else: + bldr.lookup_index = len(lookups) + lookups.append(bldr) + bldr.map_index = bldr.lookup_index + numl = len(lookups) + for i, l in enumerate(latelookups): + l.lookup_index = numl + i + l.map_index = l.lookup_index + for l in lookups + latelookups: + self.lookup_locations[tag][str(l.lookup_index)] = LookupDebugInfo( + location=str(l.location), + name=self.get_lookup_name_(l), + feature=None) + return [b.build() for b in lookups + latelookups] + + def add_lookup_to_feature_(self, lookup, feature_name): + super(MyBuilder, self).add_lookup_to_feature_(lookup, feature_name) + lookup._feature = feature_name + + +#TODO: provide more argument info +argspec = [ + ('input_fea', {'help': 'Input fea file'}, {}), + ('input_font', {'help': 'Input font file'}, {}), + ('-o', '--output', {'help': 'Output font file'}, {}), + ('-v', '--verbose', {'help': 'Repeat to increase verbosity', 'action': 'count', 'default': 0}, {}), + ('-m', '--lookupmap', {'help': 'File into which place lookup map'}, {}), + ('-l','--log',{'help': 'Optional log file'}, {'type': 'outfile', 'def': '_buildfea.log', 'optlog': True}), + ('-e','--end',{'help': 'Push lookups not in features to the end', 'action': 'store_true'}, {}), + ('-F','--front',{'help': 'Pull named lookups to the front of unnamed list', 'action': 'append'}, {}), +] + +def doit(args) : + levels = ["WARNING", "INFO", "DEBUG"] + configLogger(level=levels[min(len(levels) - 1, args.verbose)]) + + font = TTFont(args.input_font) + builder = MyBuilder(font, args.input_fea, lateSortLookups=args.end, fronts=args.front) + builder.build() + if args.lookupmap: + with open(args.lookupmap, "w") as outf: + for n, l in sorted(builder.named_lookups_.items()): + if l is not None: + outf.write("{},{},{}\n".format(n, l.table, l.map_index)) + font.save(args.output) + +def cmd(): execute(None, doit, argspec) +if __name__ == '__main__': cmd() diff --git a/src/silfont/scripts/psfchangegdlnames.py b/src/silfont/scripts/psfchangegdlnames.py new file mode 100644 index 0000000..4b2750e --- /dev/null +++ b/src/silfont/scripts/psfchangegdlnames.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +__doc__ = '''Change graphite names within GDL based on a csv list in format + old name, newname + Logs any names not in list + Also updates postscript names in postscript() statements based on psnames csv''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2016 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute +import os, re + +argspec = [ + ('input',{'help': 'Input file or folder'}, {'type': 'filename'}), + ('output',{'help': 'Output file or folder', 'nargs': '?'}, {}), + ('-n','--names',{'help': 'Names csv file'}, {'type': 'incsv', 'def': 'gdlmap.csv'}), + ('--names2',{'help': '2nd names csv file', 'nargs': '?'}, {'type': 'incsv', 'def': None}), + ('--psnames',{'help': 'PS names csv file'}, {'type': 'incsv', 'def': 'psnames.csv'}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': 'GDLchangeNames.log'})] + +def doit(args) : + logger = args.paramsobj.logger + + exceptions = ("glyph", "gamma", "greek_circ") + + # Process input which may be a single file or a directory + input = args.input + gdlfiles = [] + + if os.path.isdir(input) : + inputisdir = True + indir = input + for name in os.listdir(input) : + ext = os.path.splitext(name)[1] + if ext in ('.gdl','.gdh') : + gdlfiles.append(name) + else : + inputisdir = False + indir,inname = os.path.split(input) + gdlfiles = [inname] + + # Process output file name - execute() will not have processed file/dir name at all + output = "" if args.output is None else args.output + outdir,outfile = os.path.split(output) + if outfile != "" and os.path.splitext(outfile)[1] == "" : # if no extension on outfile, assume a dir was meant + outdir = os.path.join(outdir,outfile) + outfile = None + if outfile == "" : outfile = None + if outfile and inputisdir : logger.log("Can't specify an output file when input is a directory", "S") + outappend = None + if outdir == "" : + if outfile is None : + outappend = "_out" + else : + if outfile == gdlfiles[0] : logger.log("Specify a different output file", "S") + outdir = indir + else: + if indir == outdir : + if outfile : + if outfile == gdlfiles[0] : logger.log("Specify a different output file", "S") + else: + logger.log("Specify a different output dir", "S") + if not os.path.isdir(outdir) : logger.log("Output directory does not exist", "S") + + # Process names csv file + args.names.numfields = 2 + names = {} + for line in args.names : names[line[0]] = line[1] + + # Process names2 csv if present + names2 = args.names2 + if names2 is not None : + names2.numfields = 2 + for line in names2 : + n1 = line[0] + n2 = line[1] + if n1 in names and n2 != names[n1] : + logger.log(n1 + " in both names and names2 with different values","E") + else : + names[n1] = n2 + + # Process psnames csv file + args.psnames.numfields = 2 + psnames = {} + for line in args.psnames : psnames[line[1]] = line[0] + + missed = [] + psmissed = [] + for filen in gdlfiles: + dbg = True if filen == 'main.gdh' else False ## + file = open(os.path.join(indir,filen),"r") + if outappend : + base,ext = os.path.splitext(filen) + outfilen = base+outappend+ext + else : + outfilen = filen + outfile = open(os.path.join(outdir,outfilen),"w") + commentblock = False + cnt = 0 ## + for line in file: + cnt += 1 ## + #if cnt > 150 : break ## + line = line.rstrip() + # Skip comment blocks + if line[0:2] == "/*" : + outfile.write(line + "\n") + if line.find("*/") == -1 : commentblock = True + continue + if commentblock : + outfile.write(line + "\n") + if line.find("*/") != -1 : commentblock = False + continue + # Scan for graphite names + cpos = line.find("//") + if cpos == -1 : + scan = line + comment = "" + else : + scan = line[0:cpos] + comment = line[cpos:] + tmpline = "" + while re.search('[\s(\[,]g\w+?[\s)\],?:;=]'," "+scan+" ") : + m = re.search('[\s(\[,]g\w+?[\s)\],?:;=]'," "+scan+" ") + gname = m.group(0)[1:-1] + if gname in names : + gname = names[gname] + else : + if gname not in missed and gname not in exceptions : + logger.log(gname + " from '" + line.strip() + "' in " + filen + " missing from csv", "W") + missed.append(gname) # only log each missed name once + tmpline = tmpline + scan[lastend:m.start()] + gname + scan = scan[m.end()-2:] + tmpline = tmpline + scan + comment + + # Scan for postscript statements + scan = tmpline[0:tmpline.find("//")] if tmpline.find("//") != -1 else tmpline + newline = "" + lastend = 0 + + for m in re.finditer('postscript\(.+?\)',scan) : + psname = m.group(0)[12:-2] + if psname in psnames : + psname = psnames[psname] + else : + if psname not in psmissed : + logger.log(psname + " from '" + line.strip() + "' in " + filen + " missing from ps csv", "W") + psmissed.append(psname) # only log each missed name once + newline = newline + scan[lastend:m.start()+12] + psname + lastend = m.end()-2 + + newline = newline + tmpline[lastend:] + outfile.write(newline + "\n") + file.close() + outfile.close() + if missed != [] : logger.log("Names were missed from the csv file - see log file for details","E") + return + +def cmd() : execute(None,doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfchangettfglyphnames.py b/src/silfont/scripts/psfchangettfglyphnames.py new file mode 100644 index 0000000..6c9853e --- /dev/null +++ b/src/silfont/scripts/psfchangettfglyphnames.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +__doc__ = 'Rename the glyphs in a ttf file based on production names in a UFO' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Alan Ward' + +# Rename the glyphs in a ttf file based on production names in a UFO +# using same technique as fontmake. +# Production names come from ufo.lib.public.postscriptNames according to ufo2ft comments +# but I don't know exactly where in the UFO that is + +from silfont.core import execute +import defcon, fontTools.ttLib, ufo2ft + +argspec = [ + ('iufo', {'help': 'Input UFO folder'}, {}), + ('ittf', {'help': 'Input ttf file name'}, {}), + ('ottf', {'help': 'Output ttf file name'}, {})] + +def doit(args): + ufo = defcon.Font(args.iufo) + ttf = fontTools.ttLib.TTFont(args.ittf) + + args.logger.log('Renaming the input ttf glyphs based on production names in the UFO', 'P') + postProcessor = ufo2ft.PostProcessor(ttf, ufo) + ttf = postProcessor.process(useProductionNames=True, optimizeCFF=False) + + args.logger.log('Saving the output ttf file', 'P') + ttf.save(args.ottf) + + args.logger.log('Done', 'P') + +def cmd(): execute(None, doit, argspec) +if __name__ == '__main__': cmd() diff --git a/src/silfont/scripts/psfcheckbasicchars.py b/src/silfont/scripts/psfcheckbasicchars.py new file mode 100644 index 0000000..d7dd4f2 --- /dev/null +++ b/src/silfont/scripts/psfcheckbasicchars.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +__doc__ = '''Checks a UFO for the presence of glyphs that represent the +Recommended characters for Non-Roman fonts and warns if any are missing. +https://scriptsource.org/entry/gg5wm9hhd3''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Victor Gaultney' + +from silfont.core import execute +from silfont.util import required_chars + +argspec = [ + ('ifont',{'help': 'Input font file'}, {'type': 'infont'}), + ('-r', '--rtl', {'help': 'Also include characters just for RTL scripts', 'action': 'store_true'}, {}), + ('-s', '--silpua', {'help': 'Also include characters in SIL PUA block', 'action': 'store_true'}, {}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': '_checkbasicchars.log'})] + +def doit(args) : + font = args.ifont + logger = args.logger + + rationales = { + "A": "in Codepage 1252", + "B": "in MacRoman", + "C": "for publishing", + "D": "for Non-Roman fonts and publishing", + "E": "by Google Fonts", + "F": "by TeX for visible space", + "G": "for encoding conversion utilities", + "H": "in case Variation Sequences are defined in future", + "I": "to detect byte order", + "J": "to render combining marks in isolation", + "K": "to view sidebearings for every glyph using these characters"} + + charsets = ["basic"] + if args.rtl: charsets.append("rtl") + if args.silpua: charsets.append("sil") + + req_chars = required_chars(charsets) + + glyphlist = font.deflayer.keys() + + for glyphn in glyphlist : + glyph = font.deflayer[glyphn] + if len(glyph["unicode"]) == 1 : + unival = glyph["unicode"][0].hex + if unival in req_chars: + del req_chars[unival] + + cnt = len(req_chars) + if cnt > 0: + for usv in sorted(req_chars.keys()): + item = req_chars[usv] + psname = item["ps_name"] + gname = item["glyph_name"] + name = psname if psname == gname else psname + ", " + gname + logger.log("U+" + usv + " from the " + item["sil_set"] + + " set has no representative glyph (" + name + ")", "W") + logger.log("Rationale: This character is needed " + rationales[item["rationale"]], "I") + if item["notes"]: + logger.log(item["notes"], "I") + logger.log("There are " + str(cnt) + " required characters missing", "E") + + return + +def cmd() : execute("UFO",doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfcheckclassorders.py b/src/silfont/scripts/psfcheckclassorders.py new file mode 100644 index 0000000..1dcd517 --- /dev/null +++ b/src/silfont/scripts/psfcheckclassorders.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +'''verify classes defined in xml have correct ordering where needed + +Looks for comment lines in the classes.xml file that match the string: + *NEXT n CLASSES MUST MATCH* +where n is the number of upcoming class definitions that must result in the +same glyph alignment when glyph names are sorted by TTF order (as described +in the glyph_data.csv file). +''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Bob Hallissy' + +import re +import types +from xml.etree import ElementTree as ET +from silfont.core import execute + +argspec = [ + ('classes', {'help': 'class definition in XML format', 'nargs': '?', 'default': 'classes.xml'}, {'type': 'infile'}), + ('glyphdata', {'help': 'Glyph info csv file', 'nargs': '?', 'default': 'glyph_data.csv'}, {'type': 'incsv'}), + ('--gname', {'help': 'Column header for glyph name', 'default': 'glyph_name'}, {}), + ('--sort', {'help': 'Column header(s) for sort order', 'default': 'sort_final'}, {}), +] + +# Dictionary of glyphName : sortValue +sorts = dict() + +# Keep track of glyphs mentioned in classes but not in glyph_data.csv +missingGlyphs = set() + +def doit(args): + logger = args.logger + + # Read input csv to get glyph sort order + incsv = args.glyphdata + fl = incsv.firstline + if fl is None: logger.log("Empty input file", "S") + if args.gname in fl: + glyphnpos = fl.index(args.gname) + else: + logger.log("No" + args.gname + "field in csv headers", "S") + if args.sort in fl: + sortpos = fl.index(args.sort) + else: + logger.log('No "' + args.sort + '" heading in csv headers"', "S") + next(incsv.reader, None) # Skip first line with containing headers + for line in incsv: + glyphn = line[glyphnpos] + if len(glyphn) == 0: + continue # No need to include cases where name is blank + sorts[glyphn] = float(line[sortpos]) + + # RegEx we are looking for in comments + matchCountRE = re.compile("\*NEXT ([1-9]\d*) CLASSES MUST MATCH\*") + + # parse classes.xml but include comments + class MyTreeBuilder(ET.TreeBuilder): + def comment(self, data): + res = matchCountRE.search(data) + if res: + # record the count of classes that must match + self.start(ET.Comment, {}) + self.data(res.group(1)) + self.end(ET.Comment) + doc = ET.parse(args.classes, parser=ET.XMLParser(target=MyTreeBuilder())).getroot() + + # process results looking for both class elements and specially formatted comments + matchCount = 0 + refClassList = None + refClassName = None + + for child in doc: + if isinstance(child.tag, types.FunctionType): + # Special type used for comments + if matchCount > 0: + logger.log("Unexpected match request '{}': matching {} is not yet complete".format(child.text, refClassName), "E") + ref = None + matchCount = int(child.text) + # print "Match count = {}".format(matchCount) + + elif child.tag == 'class': + l = orderClass(child, logger) # Do this so we record classes whether we match them or not. + if matchCount > 0: + matchCount -= 1 + className = child.attrib['name'] + if refClassName is None: + refClassList = l + refLen = len(refClassList) + refClassName = className + else: + # compare ref list and l + if len(l) != refLen: + logger.log("Class {} (length {}) and {} (length {}) have unequal length".format(refClassName, refLen, className, len(l)), "E") + else: + errCount = 0 + for i in range(refLen): + if l[i][0] != refClassList[i][0]: + logger.log ("Class {} and {} inconsistent order glyphs {} and {}".format(refClassName, className, refClassList[i][2], l[i][2]), "E") + errCount += 1 + if errCount > 5: + logger.log ("Abandoning compare between Classes {} and {}".format(refClassName, className), "E") + break + if matchCount == 0: + refClassName = None + + # List glyphs mentioned in classes.xml but not present in glyph_data: + if len(missingGlyphs): + logger.log('Glyphs mentioned in classes.xml but not present in glyph_data: ' + ', '.join(sorted(missingGlyphs)), 'W') + + +classes = {} # Keep record of all classes we've seen so we can flatten references + +def orderClass(classElement, logger): + # returns a list of tuples, each containing (indexWithinClass, sortOrder, glyphName) + # list is sorted by sortOrder + glyphList = classElement.text.split() + res = [] + for i in range(len(glyphList)): + token = glyphList[i] + if token.startswith('@'): + # Nested class + cname = token[1:] + if cname in classes: + res.extend(classes[cname]) + else: + logger.log("Invalid fea: class {} referenced before being defined".format(cname),"S") + else: + # simple glyph name -- make sure it is in glyph_data: + if token in sorts: + res.append((i, sorts[token], token)) + else: + missingGlyphs.add(token) + + classes[classElement.attrib['name']] = res + return sorted(res, key=lambda x: x[1]) + + + +def cmd() : execute(None,doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfcheckftml.py b/src/silfont/scripts/psfcheckftml.py new file mode 100644 index 0000000..0ed9b48 --- /dev/null +++ b/src/silfont/scripts/psfcheckftml.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +'''Test structural integrity of one or more ftml files + +Assumes ftml files have already validated against FTML.dtd, for example by using: + xmllint --noout --dtdvalid FTML.dtd inftml.ftml + +Verifies that: + - silfont.ftml can parse the file + - every stylename is defined the <styles> list ''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2021 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Bob Hallissy' + +import glob +from silfont.ftml import Fxml, Ftest +from silfont.core import execute + +argspec = [ + ('inftml', {'help': 'Input ftml filename pattern (default: *.ftml) ', 'nargs' : '?', 'default' : '*.ftml'}, {}), +] + +def doit(args): + logger = args.logger + fnames = glob.glob(args.inftml) + if len(fnames) == 0: + logger.log(f'No files matching "{args.inftml}" found.','E') + for fname in glob.glob(args.inftml): + logger.log(f'checking {fname}', 'P') + unknownStyles = set() + usedStyles = set() + + # recursively find and check all <test> elements in a <testsgroup> + def checktestgroup(testgroup): + for test in testgroup.tests: + # Not sure why, but sub-testgroups are also included in tests, so filter those out for now + if isinstance(test, Ftest) and test.stylename: + sname = test.stylename + usedStyles.add(sname) + if sname is not None and sname not in unknownStyles and \ + not (hasStyles and sname in ftml.head.styles): + logger.log(f' stylename "{sname}" not defined in head/styles', 'E') + unknownStyles.add(sname) + # recurse to nested testgroups if any: + if testgroup.testgroups is not None: + for subgroup in testgroup.testgroups: + checktestgroup(subgroup) + + with open(fname,encoding='utf8') as f: + # Attempt to parse the ftml file + ftml = Fxml(f) + hasStyles = ftml.head.styles is not None # Whether or not any styles are defined in head element + + # Look through all tests for undefined styles: + for testgroup in ftml.testgroups: + checktestgroup(testgroup) + + if hasStyles: + # look for unused styles: + for style in ftml.head.styles: + if style not in usedStyles: + logger.log(f' defined style "{style}" not used in any test', 'W') + +def cmd() : execute(None,doit, argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfcheckglyphinventory.py b/src/silfont/scripts/psfcheckglyphinventory.py new file mode 100644 index 0000000..4a805d4 --- /dev/null +++ b/src/silfont/scripts/psfcheckglyphinventory.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +__doc__ = '''Warn for differences in glyph inventory and encoding between UFO and input file (e.g., glyph_data.csv). +Input file can be: + - simple text file with one glyph name per line + - csv file with headers, using headers "glyph_name" and, if present, "USV"''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2020-2023 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Bob Hallissy' + +from silfont.core import execute + +argspec = [ + ('ifont', {'help': 'Input UFO'}, {'type': 'infont'}), + ('-i', '--input', {'help': 'Input text file, default glyph_data.csv in current directory', 'default': 'glyph_data.csv'}, {'type': 'incsv'}), + ('--indent', {'help': 'size of indent (default 10)', 'type': int, 'default': 10}, {}), + ('-l', '--log', {'help': 'Log file'}, {'type': 'outfile', 'def': '_checkinventory.log'})] + +def doit(args): + font = args.ifont + incsv = args.input + logger = args.logger + indent = ' '*args.indent + + if not (args.quiet or 'scrlevel' in args.paramsobj.sets['command line']): + logger.raisescrlevel('W') # Raise level to W if not already W or higher + + def csvWarning(msg, exception=None): + m = f'glyph_data line {incsv.line_num}: {msg}' + if exception is not None: + m += '; ' + exception.message + logger.log(m, 'W') + + # Get glyph names and encoding from input file + glyphFromCSVuid = {} + uidsFromCSVglyph = {} + + # Identify file format (plain text or csv) from first line + # If csv file, it must have headers for "glyph_name" and "USV" + fl = incsv.firstline + if fl is None: logger.log('Empty input file', 'S') + numfields = len(fl) + incsv.numfields = numfields + usvCol = None # Use this as a flag later to determine whether to check USV inventory + if numfields > 1: # More than 1 column, so must have headers + # Required columns: + try: + nameCol = fl.index('glyph_name'); + except ValueError as e: + logger.log('Missing csv input field: ' + e.message, 'S') + except Exception as e: + logger.log('Error reading csv input field: ' + e.message, 'S') + # Optional columns: + usvCol = fl.index('USV') if 'USV' in fl else None + + next(incsv.reader, None) # Skip first line with headers in + + glyphList = set() + for line in incsv: + gname = line[nameCol] + if len(gname) == 0 or line[0].strip().startswith('#'): + continue # No need to include cases where name is blank or comment + if gname in glyphList: + csvWarning(f'glyph name {gname} previously seen; ignored') + continue + glyphList.add(gname) + + if usvCol: + # Process USV field, which can be: + # empty string -- unencoded glyph + # single USV -- encoded glyph + # USVs connected by '_' -- ligature (in glyph_data for test generation, not glyph encoding) + # space-separated list of the above, where presence of multiple USVs indicates multiply-encoded glyph + for usv in line[usvCol].split(): + if '_' in usv: + # ignore ligatures -- these are for test generation, not encoding + continue + try: + uid = int(usv, 16) + except Exception as e: + csvWarning("invalid USV '%s' (%s); ignored: " % (usv, e.message)) + + if uid in glyphFromCSVuid: + csvWarning('USV %04X previously seen; ignored' % uid) + else: + # Remember this glyph encoding + glyphFromCSVuid[uid] = gname + uidsFromCSVglyph.setdefault(gname, set()).add(uid) + elif numfields == 1: # Simple text file. + glyphList = set(line[0] for line in incsv) + else: + logger.log('Invalid csv file', 'S') + + # Get the list of glyphs in the UFO + ufoList = set(font.deflayer.keys()) + + notInUFO = glyphList - ufoList + notInGlyphData = ufoList - glyphList + + if len(notInUFO): + logger.log('Glyphs present in glyph_data but missing from UFO:\n' + '\n'.join(indent + g for g in sorted(notInUFO)), 'W') + + if len(notInGlyphData): + logger.log('Glyphs present in UFO but missing from glyph_data:\n' + '\n'.join(indent + g for g in sorted(notInGlyphData)), 'W') + + if len(notInUFO) == 0 and len(notInGlyphData) == 0: + logger.log('No glyph inventory differences found', 'P') + + if usvCol: + # We can check encoding of glyphs in common + inBoth = glyphList & ufoList # Glyphs we want to examine + + csvEncodings = set(f'{gname}|{uid:04X}' for gname in filter(lambda x: x in uidsFromCSVglyph, inBoth) for uid in uidsFromCSVglyph[gname] ) + ufoEncodings = set(f'{gname}|{int(u.hex, 16):04X}' for gname in inBoth for u in font.deflayer[gname]['unicode']) + + notInUFO = csvEncodings - ufoEncodings + notInGlyphData = ufoEncodings - csvEncodings + + if len(notInUFO): + logger.log('Encodings present in glyph_data but missing from UFO:\n' + '\n'.join(indent + g for g in sorted(notInUFO)), 'W') + + if len(notInGlyphData): + logger.log('Encodings present in UFO but missing from glyph_data:\n' + '\n'.join(indent + g for g in sorted(notInGlyphData)), 'W') + + if len(notInUFO) == 0 and len(notInGlyphData) == 0: + logger.log('No glyph encoding differences found', 'P') + + else: + logger.log('Glyph encodings not compared', 'P') + + +def cmd(): execute('UFO', doit, argspec) +if __name__ == '__main__': cmd() diff --git a/src/silfont/scripts/psfcheckinterpolatable.py b/src/silfont/scripts/psfcheckinterpolatable.py new file mode 100644 index 0000000..b4b5e71 --- /dev/null +++ b/src/silfont/scripts/psfcheckinterpolatable.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +__doc__ = '''Check that the ufos in a designspace file are interpolatable''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2021 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute +from fontParts.world import OpenFont +import fontTools.designspaceLib as DSD + +argspec = [ + ('designspace', {'help': 'Design space file'}, {'type': 'filename'}), + ('-l','--log', {'help': 'Log file'}, {'type': 'outfile', 'def': '_checkinterp.log'}), + ] + +def doit(args) : + logger = args.logger + + ds = DSD.DesignSpaceDocument() + ds.read(args.designspace) + if len(ds.sources) == 1: logger.log("The design space file has only one source UFO", "S") + + # Find all the UFOs from the DS Sources. Where there are more than 2, the primary one will be considered to be + # the one where info copy="1" is set (as per psfsyncmasters). If not set for any, use the first ufo. + pufo = None + otherfonts = {} + for source in ds.sources: + ufo = source.path + try: + font = OpenFont(ufo) + except Exception as e: + logger.log("Unable to open " + ufo, "S") + if source.copyInfo: + if pufo: logger.log('Multiple fonts with <info copy="1" />', "S") + pufo = ufo + pfont = font + else: + otherfonts[ufo] = font + if pufo is None: # If we can't identify the primary font by conyInfo, just use the first one + pufo = ds.sources[0].path + pfont = otherfonts[pufo] + del otherfonts[pufo] + + pinventory = set(glyph.name for glyph in pfont) + + for oufo in otherfonts: + logger.log(f'Comparing {pufo} with {oufo}', 'P') + ofont = otherfonts[oufo] + oinventory = set(glyph.name for glyph in ofont) + + if pinventory != oinventory: + logger.log("The glyph inventories in the two UFOs differ", "E") + for glyphn in sorted(pinventory - oinventory): + logger.log(f'{glyphn} is only in {pufo}', "W") + for glyphn in sorted(oinventory - pinventory): + logger.log(f'{glyphn} is only in {oufo}', "W") + else: + logger.log("The UFOs have the same glyph inventories", "P") + # Are glyphs compatible for interpolation + incompatibles = {} + for glyphn in pinventory & oinventory: + compatible, report = pfont[glyphn].isCompatible(ofont[glyphn]) + if not compatible: incompatibles[glyphn] = report + if incompatibles: + logger.log(f'{len(incompatibles)} glyphs are not interpolatable', 'E') + for glyphn in sorted(incompatibles): + logger.log(f'{glyphn} is not interpolatable', 'W') + logger.log(incompatibles[glyphn], "I") + if logger.scrlevel == "W": logger.log("To see detailed reports run with scrlevel and/or loglevel set to I") + else: + logger.log("All the glyphs are interpolatable", "P") + +def cmd() : execute(None,doit, argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfcheckproject.py b/src/silfont/scripts/psfcheckproject.py new file mode 100644 index 0000000..9575b17 --- /dev/null +++ b/src/silfont/scripts/psfcheckproject.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +__doc__ = '''Run project-wide checks. Currently just checking glyph inventory and unicode values for ufo sources in +the designspace files supplied but maybe expanded to do more checks later''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2022 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute, splitfn +import fontTools.designspaceLib as DSD +import glob, os +import silfont.ufo as UFO +import silfont.etutil as ETU + +argspec = [ + ('ds', {'help': 'designspace files to check; wildcards allowed', 'nargs': "+"}, {'type': 'filename'}) +] + +## Quite a few things are being set and then not used at the moment - this is to allow for more checks to be added in the future. +# For example projectroot, psource + +def doit(args): + logger = args.logger + + # Open all the supplied DS files and ufos within them + dsinfos = [] + failures = False + for pattern in args.ds: + cnt = 0 + for fullpath in glob.glob(pattern): + cnt += 1 + logger.log(f'Opening {fullpath}', 'P') + try: + ds = DSD.DesignSpaceDocument.fromfile(fullpath) + except Exception as e: + logger.log(f'Error opening {fullpath}: {e}', 'E') + failures = True + break + dsinfos.append({'dspath': fullpath, 'ds': ds}) + if not cnt: logger.log(f'No files matched {pattern}', "S") + if failures: logger.log("Failed to open all the designspace files", "S") + + # Find the project root based on first ds assuming the project root is one level above a source directory containing the DS files + path = dsinfos[0]['dspath'] + (path, base, ext) = splitfn(path) + (parent,dir) = os.path.split(path) + projectroot = parent if dir == "source" else None + logger.log(f'Project root: {projectroot}', "V") + + # Find and open all the unique UFO sources in the DSs + ufos = {} + refufo = None + for dsinfo in dsinfos: + logger.log(f'Processing {dsinfo["dspath"]}', "V") + ds = dsinfo['ds'] + for source in ds.sources: + if source.path not in ufos: + ufos[source.path] = Ufo(source, logger) + if not refufo: refufo = source.path # For now use the first found. Need to work out how to choose the best one + + refunicodes = ufos[refufo].unicodes + refglyphlist = set(refunicodes) + (path,refname) = os.path.split(refufo) + + # Now compare with other UFOs + logger.log(f'Comparing glyph inventory and unicode values with those in {refname}', "P") + for ufopath in ufos: + if ufopath == refufo: continue + ufo = ufos[ufopath] + logger.log(f'Checking {ufo.name}', "I") + unicodes = ufo.unicodes + glyphlist = set(unicodes) + missing = refglyphlist - glyphlist + extras = glyphlist - refglyphlist + both = glyphlist - extras + if missing: logger.log(f'These glyphs are missing from {ufo.name}: {str(list(missing))}', 'E') + if extras: logger.log(f'These extra glyphs are in {ufo.name}: {", ".join(extras)}', 'E') + valdiff = [f'{g}: {str(unicodes[g])}/{str(refunicodes[g])}' + for g in both if refunicodes[g] != unicodes[g]] + if valdiff: + valdiff = "\n".join(valdiff) + logger.log(f'These glyphs in {ufo.name} have different unicode values to those in {refname}:\n' + f'{valdiff}', 'E') + +class Ufo(object): # Read just the bits for UFO needed for current checks for efficientcy reasons + def __init__(self, source, logger): + self.source = source + (path, self.name) = os.path.split(source.path) + self.logger = logger + self.ufodir = source.path + self.unicodes = {} + if not os.path.isdir(self.ufodir): logger.log(self.ufodir + " in designspace doc does not exist", "S") + try: + self.layercontents = UFO.Uplist(font=None, dirn=self.ufodir, filen="layercontents.plist") + except Exception as e: + logger.log("Unable to open layercontents.plist in " + self.ufodir, "S") + for i in sorted(self.layercontents.keys()): + layername = self.layercontents[i][0].text + if layername != 'public.default': continue + layerdir = self.layercontents[i][1].text + fulldir = os.path.join(self.ufodir, layerdir) + self.contents = UFO.Uplist(font=None, dirn=fulldir, filen="contents.plist") + for glyphn in sorted(self.contents.keys()): + glifn = self.contents[glyphn][1].text + glyph = ETU.xmlitem(os.path.join(self.ufodir,layerdir), glifn, logger=logger) + unicode = None + for x in glyph.etree: + if x.tag == 'unicode': + unicode = x.attrib['hex'] + break + self.unicodes[glyphn] = unicode + +def cmd(): execute('', doit, argspec) +if __name__ == '__main__': cmd() diff --git a/src/silfont/scripts/psfcompdef2xml.py b/src/silfont/scripts/psfcompdef2xml.py new file mode 100644 index 0000000..e447a6f --- /dev/null +++ b/src/silfont/scripts/psfcompdef2xml.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +__doc__ = 'convert composite definition file to XML format' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Rowe' + +from silfont.core import execute +from silfont.etutil import ETWriter +from silfont.comp import CompGlyph +from xml.etree import ElementTree as ET + +# specify three parameters: input file (single line format), output file (XML format), log file +# and optional -p indentFirst " " -p indentIncr " " -p "PSName,UID,with,at,x,y" for XML formatting. +argspec = [ + ('input',{'help': 'Input file of CD in single line format'}, {'type': 'infile'}), + ('output',{'help': 'Output file of CD in XML format'}, {'type': 'outfile', 'def': '_out.xml'}), + ('log',{'help': 'Log file'},{'type': 'outfile', 'def': '_log.txt'}), + ('-p','--params',{'help': 'XML formatting parameters: indentFirst, indentIncr, attOrder','action': 'append'}, {'type': 'optiondict'})] + +def doit(args) : + ofile = args.output + lfile = args.log + filelinecount = 0 + linecount = 0 + elementcount = 0 + cgobj = CompGlyph() + f = ET.Element('font') + for line in args.input.readlines(): + filelinecount += 1 + testline = line.strip() + if len(testline) > 0 and testline[0:1] != '#': # not whitespace or comment + linecount += 1 + cgobj.CDline=line + cgobj.CDelement=None + try: + cgobj.parsefromCDline() + if cgobj.CDelement != None: + f.append(cgobj.CDelement) + elementcount += 1 + except ValueError as e: + lfile.write("Line "+str(filelinecount)+": "+str(e)+'\n') + if linecount != elementcount: + lfile.write("Lines read from input file: " + str(filelinecount)+'\n') + lfile.write("Lines parsed (excluding blank and comment lines): " + str(linecount)+'\n') + lfile.write("Valid glyphs found: " + str(elementcount)+'\n') +# instead of simple serialization with: ofile.write(ET.tostring(f)) +# create ETWriter object and specify indentation and attribute order to get normalized output + indentFirst = " " + indentIncr = " " + attOrder = "PSName,UID,with,at,x,y" + for k in args.params: + if k == 'indentIncr': indentIncr = args.params['indentIncr'] + elif k == 'indentFirst': indentFirst = args.params['indentFirst'] + elif k == 'attOrder': attOrder = args.params['attOrder'] + x = attOrder.split(',') + attributeOrder = dict(zip(x,range(len(x)))) + etwobj=ETWriter(f, indentFirst=indentFirst, indentIncr=indentIncr, attributeOrder=attributeOrder) + ofile.write(etwobj.serialize_xml()) + + return + +def cmd() : execute(None,doit,argspec) +if __name__ == "__main__": cmd() + diff --git a/src/silfont/scripts/psfcompressgr.py b/src/silfont/scripts/psfcompressgr.py new file mode 100644 index 0000000..67d0f33 --- /dev/null +++ b/src/silfont/scripts/psfcompressgr.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +__doc__ = 'Compress Graphite tables in a font' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Martin Hosken' + +argspec = [ + ('ifont',{'help': 'Input TTF'}, {'type': 'infont'}), + ('ofont',{'help': 'Output TTF','nargs': '?' }, {'type': 'outfont'}), + ('-l','--log',{'help': 'Optional log file'}, {'type': 'outfile', 'def': '_compressgr', 'optlog': True}) +] + +from silfont.core import execute +from fontTools.ttLib.tables.DefaultTable import DefaultTable +import lz4.block +import sys, struct + +class lz4tuple(object) : + def __init__(self, start) : + self.start = start + self.literal = start + self.literal_len = 0 + self.match_dist = 0 + self.match_len = 0 + self.end = 0 + + def __str__(self) : + return "lz4tuple(@{},{}+{},-{}+{})={}".format(self.start, self.literal, self.literal_len, self.match_dist, self.match_len, self.end) + +def read_literal(t, dat, start, datlen) : + if t == 15 and start < datlen : + v = ord(dat[start:start+1]) + t += v + while v == 0xFF and start < datlen : + start += 1 + v = ord(dat[start:start+1]) + t += v + start += 1 + return (t, start) + +def write_literal(num, shift) : + res = [] + if num > 14 : + res.append(15 << shift) + num -= 15 + while num > 255 : + res.append(255) + num -= 255 + res.append(num) + else : + res.append(num << shift) + return bytearray(res) + +def parseTuple(dat, start, datlen) : + res = lz4tuple(start) + token = ord(dat[start:start+1]) + (res.literal_len, start) = read_literal(token >> 4, dat, start+1, datlen) + res.literal = start + start += res.literal_len + res.end = start + if start > datlen - 2 : + return res + res.match_dist = ord(dat[start:start+1]) + (ord(dat[start+1:start+2]) << 8) + start += 2 + (res.match_len, start) = read_literal(token & 0xF, dat, start, datlen) + res.end = start + return res + +def compressGr(dat, version) : + if ord(dat[1:2]) < version : + vstr = bytes([version]) if sys.version_info.major > 2 else chr(version) + dat = dat[0:1] + vstr + dat[2:] + datc = lz4.block.compress(dat[:-4], mode='high_compression', compression=16, store_size=False) + # now find the final tuple + end = len(datc) + start = 0 + curr = lz4tuple(start) + while curr.end < end : + start = curr.end + curr = parseTuple(datc, start, end) + if curr.end > end : + print("Sync error: {!s}".format(curr)) + newend = write_literal(curr.literal_len + 4, 4) + datc[curr.literal:curr.literal+curr.literal_len+1] + dat[-4:] + lz4hdr = struct.pack(">L", (1 << 27) + (len(dat) & 0x7FFFFFF)) + return dat[0:4] + lz4hdr + datc[0:curr.start] + newend + +def doit(args) : + infont = args.ifont + for tag, version in (('Silf', 5), ('Glat', 3)) : + dat = infont.getTableData(tag) + newdat = bytes(compressGr(dat, version)) + table = DefaultTable(tag) + table.decompile(newdat, infont) + infont[tag] = table + return infont + +def cmd() : execute('FT', doit, argspec) +if __name__ == "__main__" : cmd() + diff --git a/src/silfont/scripts/psfcopyglyphs.py b/src/silfont/scripts/psfcopyglyphs.py new file mode 100644 index 0000000..055407a --- /dev/null +++ b/src/silfont/scripts/psfcopyglyphs.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +__doc__ = """Copy glyphs from one UFO to another""" +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Bob Hallissy' + +from xml.etree import ElementTree as ET +from silfont.core import execute +from silfont.ufo import makeFileName, Uglif +import re + +argspec = [ + ('ifont',{'help': 'Input font file'}, {'type': 'infont'}), + ('ofont',{'help': 'Output font file','nargs': '?' }, {'type': 'outfont'}), + ('-s','--source',{'help': 'Font to get glyphs from'}, {'type': 'infont'}), + ('-i','--input',{'help': 'Input csv file'}, {'type': 'incsv', 'def': 'glyphlist.csv'}), + ('-f','--force',{'help' : 'Overwrite existing glyphs in the font', 'action' : 'store_true'}, {}), + ('-l','--log',{'help': 'Set log file name'}, {'type': 'outfile', 'def': '_copy.log'}), + ('-n', '--name', {'help': 'Include glyph named name', 'action': 'append'}, {}), + ('--rename',{'help' : 'Rename glyphs to names in this column'}, {}), + ('--unicode', {'help': 'Re-encode glyphs to USVs in this column'}, {}), + ('--scale',{'type' : float, 'help' : 'Scale glyphs by this factor'}, {}) +] + +class Glyph: + """details about a glyph we have, or need to, copy; mostly just for syntactic sugar""" + + # Glyphs that are used *only* as component glyphs may have to be renamed if there already exists a glyph + # by the same name in the target font. we compute a new name by appending .copy1, .copy2, etc until we get a + # unique name. We keep track of the mapping from source font glyphname to target font glyphname using a dictionary. + # For ease of use, glyphs named by the input file (which won't have their names changed, see --force) will also + # be added to this dictionary because they can also be used as components. + nameMap = dict() + + def __init__(self, oldname, newname="", psname="", dusv=None): + self.oldname = oldname + self.newname = newname or oldname + self.psname = psname or None + self.dusv = dusv or None + # Keep track of old-to-new name mapping + Glyph.nameMap[oldname] = self.newname + + +# Mapping from decimal USV to glyphname in target font +dusv2gname = None + +# RE for parsing glyph names and peeling off the .copyX if present in order to search for a unique name to use: +gcopyRE = re.compile(r'(^.+?)(?:\.copy(\d+))?$') + + +def copyglyph(sfont, tfont, g, args): + """copy glyph from source font to target font""" + # Generally, 't' variables are target, 's' are source. E.g., tfont is target font. + + global dusv2gname + if not dusv2gname: + # Create mappings to find existing glyph name from decimal usv: + dusv2gname = {int(unicode.hex, 16): gname for gname in tfont.deflayer for unicode in tfont.deflayer[gname]['unicode']} + # NB: Assumes font is well-formed and has at most one glyph with any particular Unicode value. + + # The layer where we want the copied glyph: + tlayer = tfont.deflayer + + # if new name present in target layer, delete it. + if g.newname in tlayer: + # New name is already in font: + tfont.logger.log("Replacing glyph '{0}' with new glyph".format(g.newname), "V") + glyph = tlayer[g.newname] + # While here, remove from our mapping any Unicodes from the old glyph: + for unicode in glyph["unicode"]: + dusv = int(unicode.hex, 16) + if dusv in dusv2gname: + del dusv2gname[dusv] + # Ok, remove old glyph from the layer + tlayer.delGlyph(g.newname) + else: + # New name is not in the font: + tfont.logger.log("Adding glyph '{0}'".format(g.newname), "V") + + # Create new glyph + glyph = Uglif(layer = tlayer) + # Set etree from source glyph + glyph.etree = ET.fromstring(sfont.deflayer[g.oldname].inxmlstr) + glyph.process_etree() + # Rename the glyph if needed + if glyph.name != g.newname: + # Use super to bypass normal glyph renaming logic since it isn't yet in the layer + super(Uglif, glyph).__setattr__("name", g.newname) + # add new glyph to layer: + tlayer.addGlyph(glyph) + tfont.logger.log("Added glyph '{0}'".format(g.newname), "V") + + # todo: set psname if requested; adjusting any other glyphs in the font as needed. + + # Adjust encoding of new glyph + if args.unicode: + # First remove any encodings the copied glyph had in the source font: + for i in range(len(glyph['unicode']) - 1, -1, -1): + glyph.remove('unicode', index=i) + if g.dusv: + # we want this glyph to be encoded. + # First remove this Unicode from any other glyph in the target font + if g.dusv in dusv2gname: + oglyph = tlayer[dusv2gname[g.dusv]] + for unicode in oglyph["unicode"]: + if int(unicode.hex,16) == g.dusv: + oglyph.remove("unicode", object=unicode) + tfont.logger.log("Removed USV {0:04X} from existing glyph '{1}'".format(g.dusv,dusv2gname[g.dusv]), "V") + break + # Now add and record it: + glyph.add("unicode", {"hex": '{:04X}'.format(g.dusv)}) + dusv2gname[g.dusv] = g.newname + tfont.logger.log("Added USV {0:04X} to glyph '{1}'".format(g.dusv, g.newname), "V") + + # Scale glyph if desired + if args.scale: + for e in glyph.etree.iter(): + for attr in ('width', 'height', 'x', 'y', 'xOffset', 'yOffset'): + if attr in e.attrib: e.set(attr, str(int(float(e.get(attr))* args.scale))) + + # Look through components, adjusting names and finding out if we need to copy some. + for component in glyph.etree.findall('./outline/component[@base]'): + oldname = component.get('base') + # Note: the following will cause recursion: + component.set('base', copyComponent(sfont, tfont, oldname ,args)) + + + +def copyComponent(sfont, tfont, oldname, args): + """copy component glyph if not already copied; make sure name and psname are unique; return its new name""" + if oldname in Glyph.nameMap: + # already copied + return Glyph.nameMap[oldname] + + # if oldname is already in the target font, make up a new name by adding ".copy1", incrementing as necessary + if oldname not in tfont.deflayer: + newname = oldname + tfont.logger.log("Copying component '{0}' with existing name".format(oldname), "V") + else: + x = gcopyRE.match(oldname) + base = x.group(1) + try: i = int(x.group(2)) + except: i = 1 + while "{0}.copy{1}".format(base,i) in tfont.deflayer: + i += 1 + newname = "{0}.copy{1}".format(base,i) + tfont.logger.log("Copying component '{0}' with new name '{1}'".format(oldname, newname), "V") + + # todo: something similar to above but for psname + + # Now copy the glyph, giving it new name if needed. + copyglyph(sfont, tfont, Glyph(oldname, newname), args) + + return newname + +def doit(args) : + sfont = args.source # source UFO + tfont = args.ifont # target UFO + incsv = args.input + logger = args.logger + + # Get headings from csvfile: + fl = incsv.firstline + if fl is None: logger.log("Empty input file", "S") + numfields = len(fl) + incsv.numfields = numfields + # defaults for single column csv (no headers): + nameCol = 0 + renameCol = None + psCol = None + usvCol = None + if numfields > 1 or args.rename or args.unicode: + # required columns: + try: + nameCol = fl.index('glyph_name'); + if args.rename: + renameCol = fl.index(args.rename); + if args.unicode: + usvCol = fl.index(args.unicode); + except ValueError as e: + logger.log('Missing csv input field: ' + e.message, 'S') + except Exception as e: + logger.log('Error reading csv input field: ' + e.message, 'S') + # optional columns + psCol = fl.index('ps_name') if 'ps_name' in fl else None + if 'glyph_name' in fl: + next(incsv.reader, None) # Skip first line with headers in + + # list of glyphs to copy + glist = list() + + def checkname(oldname, newname = None): + if not newname: newname = oldname + if oldname in Glyph.nameMap: + logger.log("Line {0}: Glyph '{1}' specified more than once; only the first kept".format(incsv.line_num, oldname), 'W') + elif oldname not in sfont.deflayer: + logger.log("Line {0}: Glyph '{1}' is not in source font; skipping".format(incsv.line_num, oldname),"W") + elif newname in tfont.deflayer and not args.force: + logger.log("Line {0}: Glyph '{1}' already present; skipping".format(incsv.line_num, newname), "W") + else: + return True + return False + + # glyphs specified in csv file + for r in incsv: + oldname = r[nameCol] + newname = r[renameCol] if args.rename else oldname + psname = r[psCol] if psCol is not None else None + if args.unicode and r[usvCol]: + # validate USV: + try: + dusv = int(r[usvCol],16) + except ValueError: + logger.log("Line {0}: Invalid USV '{1}'; ignored.".format(incsv.line_num, r[usvCol]), "W") + dusv = None + else: + dusv = None + + if checkname(oldname, newname): + glist.append(Glyph(oldname, newname, psname, dusv)) + + # glyphs specified on the command line + if args.name: + for gname in args.name: + if checkname(gname): + glist.append(Glyph(gname)) + + # Ok, now process them: + if len(glist) == 0: + logger.log("No glyphs to copy", "S") + + # copy glyphs by name + while len(glist) : + g = glist.pop(0) + tfont.logger.log("Copying source glyph '{0}' as '{1}'{2}".format(g.oldname, g.newname, + " (U+{0:04X})".format(g.dusv) if g.dusv else ""), "I") + copyglyph(sfont, tfont, g, args) + + return tfont + +def cmd() : execute("UFO",doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfcopymeta.py b/src/silfont/scripts/psfcopymeta.py new file mode 100644 index 0000000..8b67505 --- /dev/null +++ b/src/silfont/scripts/psfcopymeta.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +__doc__ = '''Copy metadata between fonts in different (related) families +Usually run against the master (regular) font in each family then data synced within family afterwards''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute +import silfont.ufo as UFO +from xml.etree import ElementTree as ET + +argspec = [ + ('fromfont',{'help': 'From font file'}, {'type': 'infont'}), + ('tofont',{'help': 'To font file'}, {'type': 'infont'}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': '_copymeta.log'}), + ('-r','--reportonly', {'help': 'Report issues but no updating', 'action': 'store_true', 'default': False},{}) + ] + +def doit(args) : + + fields = ["copyright", "openTypeNameDescription", "openTypeNameDesigner", "openTypeNameDesignerURL", "openTypeNameLicense", # General feilds + "openTypeNameLicenseURL", "openTypeNameManufacturer", "openTypeNameManufacturerURL", "openTypeOS2CodePageRanges", + "openTypeOS2UnicodeRanges", "openTypeOS2VendorID", "trademark", + "openTypeNameVersion", "versionMajor", "versionMinor", # Version fields + "ascender", "descender", "openTypeHheaAscender", "openTypeHheaDescender", "openTypeHheaLineGap", # Design fields + "openTypeOS2TypoAscender", "openTypeOS2TypoDescender", "openTypeOS2TypoLineGap", "openTypeOS2WinAscent", "openTypeOS2WinDescent"] + libfields = ["public.postscriptNames", "public.glyphOrder", "com.schriftgestaltung.glyphOrder"] + + fromfont = args.fromfont + tofont = args.tofont + logger = args.logger + reportonly = args.reportonly + + updatemessage = " to be updated: " if reportonly else " updated: " + precision = fromfont.paramset["precision"] + # Increase screen logging level to W unless specific level supplied on command-line + if not(args.quiet or "scrlevel" in args.paramsobj.sets["command line"]) : logger.scrlevel = "W" + + # Process fontinfo.plist + ffi = fromfont.fontinfo + tfi = tofont.fontinfo + fupdated = False + for field in fields: + if field in ffi : + felem = ffi[field][1] + ftag = felem.tag + ftext = felem.text + if ftag == 'real' : ftext = processnum(ftext,precision) + message = field + updatemessage + + if field in tfi : # Need to compare values to see if update is needed + telem = tfi[field][1] + ttag = telem.tag + ttext = telem.text + if ttag == 'real' : ttext = processnum(ttext,precision) + + if ftag in ("real", "integer", "string") : + if ftext != ttext : + if field == "openTypeNameLicense" : # Too long to display all + addmess = " Old: '" + ttext[0:80] + "...' New: '" + ftext[0:80] + "...'" + else: addmess = " Old: '" + ttext + "' New: '" + str(ftext) + "'" + telem.text = ftext + logger.log(message + addmess, "W") + fupdated = True + elif ftag in ("true, false") : + if ftag != ttag : + fti.setelem(field, ET.fromstring("<" + ftag + "/>")) + logger.log(message + " Old: '" + ttag + "' New: '" + str(ftag) + "'", "W") + fupdated = True + elif ftag == "array" : # Assume simple array with just values to compare + farray = [] + for subelem in felem : farray.append(subelem.text) + tarray = [] + for subelem in telem : tarray.append(subelem.text) + if farray != tarray : + tfi.setelem(field, ET.fromstring(ET.tostring(felem))) + logger.log(message + "Some values different Old: " + str(tarray) + " New: " + str(farray), "W") + fupdated = True + else : logger.log("Non-standard fontinfo field type: "+ ftag + " in " + fontname, "S") + else : + tfi.addelem(field, ET.fromstring(ET.tostring(felem))) + logger.log(message + "is missing from destination font so will be copied from source font", "W") + fupdated = True + else: # Field not in from font + if field in tfi : + logger.log( field + " is missing from source font but present in destination font", "E") + else : + logger.log( field + " is in neither font", "W") + + # Process lib.plist - currently just public.postscriptNames and glyph order fields which are all simple dicts or arrays + flib = fromfont.lib + tlib = tofont.lib + lupdated = False + for field in libfields: + action = None + if field in flib: + if field in tlib: # Need to compare values to see if update is needed + if flib.getval(field) != tlib.getval(field): + action = "Updatefield" + else: + action = "Copyfield" + else: + action = "Error" if field == ("public.GlyphOrder", "public.postscriptNames") else "Warn" + issue = field + " not in source font lib.plist" + + # Process the actions, create log messages etc + if action is None or action == "Ignore": + pass + elif action == "Warn": + logger.log(field + " needs manual correction: " + issue, "W") + elif action == "Error": + logger.log(field + " needs manual correction: " + issue, "E") + elif action in ("Updatefield", "Copyfield"): # Updating actions + lupdated = True + message = field + updatemessage + if action == "Copyfield": + message = message + "is missing so will be copied from source font" + tlib.addelem(field, ET.fromstring(ET.tostring(flib[field][1]))) + elif action == "Updatefield": + message = message + "Some values different" + tlib.setelem(field, ET.fromstring(ET.tostring(flib[field][1]))) + logger.log(message, "W") + else: + logger.log("Uncoded action: " + action + " - oops", "X") + + # Now update on disk + if not reportonly: + if fupdated: + logger.log("Writing updated fontinfo.plist", "P") + UFO.writeXMLobject(tfi, tofont.outparams, tofont.ufodir, "fontinfo.plist", True, fobject=True) + if lupdated: + logger.log("Writing updated lib.plist", "P") + UFO.writeXMLobject(tlib, tofont.outparams, tofont.ufodir, "lib.plist", True, fobject=True) + + return + + +def processnum(text, precision) : # Apply same processing to real numbers that normalization will + if precision is not None: + val = round(float(text), precision) + if val == int(val) : val = int(val) # Removed trailing decimal .0 + text = str(val) + return text + + +def cmd(): execute("UFO",doit, argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfcreateinstances.py b/src/silfont/scripts/psfcreateinstances.py new file mode 100644 index 0000000..d623390 --- /dev/null +++ b/src/silfont/scripts/psfcreateinstances.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +__doc__ = 'Generate instance UFOs from a designspace document and master UFOs' + +# Python 2.7 script to build instance UFOs from a designspace document +# If a file is given, all instances are built +# A particular instance to build can be specified using the -i option +# and the 'name' attribute value for an 'instance' element in the designspace file +# Or it can be specified using the -a and -v options +# to specify any attribute and value pair for an 'instance' in the designspace file +# If more than one instances matches, all will be built +# A prefix for the output path can be specified (for smith processing) +# If the location of an instance UFO matches a master's location, +# glyphs are copied instead of calculated +# This allows instances to build with glyphs that are not interpolatable +# An option exists to calculate glyphs instead of copying them +# If a folder is given using an option, all instances in all designspace files are built +# Specifying an instance to build or an output path prefix is not supported with a folder +# Also, all glyphs will be calculated + +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Alan Ward' + +import os, re +from mutatorMath.ufo.document import DesignSpaceDocumentReader +from mutatorMath.ufo.instance import InstanceWriter +from fontMath.mathGlyph import MathGlyph +from mutatorMath.ufo import build as build_designspace +from silfont.core import execute + +argspec = [ + ('designspace_path', {'help': 'Path to designspace document (or folder of them)'}, {}), + ('-i', '--instanceName', {'help': 'Font name for instance to build'}, {}), + ('-a', '--instanceAttr', {'help': 'Attribute used to specify instance to build'}, {}), + ('-v', '--instanceVal', {'help': 'Value of attribute specifying instance to build'}, {}), + ('-f', '--folder', {'help': 'Build all designspace files in a folder','action': 'store_true'}, {}), + ('-o', '--output', {'help': 'Prepend path to all output paths'}, {}), + ('--forceInterpolation', {'help': 'If an instance matches a master, calculate glyphs instead of copying them', + 'action': 'store_true'}, {}), + ('--roundInstances', {'help': 'Apply integer rounding to all geometry when interpolating', + 'action': 'store_true'}, {}), + ('-l','--log',{'help': 'Log file (default: *_createinstances.log)'}, {'type': 'outfile', 'def': '_createinstances.log'}), + ('-W','--weightfix',{'help': 'Enable RIBBI style weight fixing', 'action': 'store_true'}, {}), +] + +# Class factory to wrap a subclass in a closure to store values not defined in the original class +# that our method overrides will utilize +# The class methods will fail unless the class is generated by the factory, which is enforced by scoping +# Using class attribs or global variables would violate encapsulation even more +# and would only allow for one instance of the class + +weightClasses = { + 'bold': 700 +} + +def InstanceWriterCF(output_path_prefix, calc_glyphs, fix_weight): + + class LocalInstanceWriter(InstanceWriter): + fixWeight = fix_weight + + def __init__(self, path, *args, **kw): + if output_path_prefix: + path = os.path.join(output_path_prefix, path) + return super(LocalInstanceWriter, self).__init__(path, *args, **kw) + + # Override the method used to calculate glyph geometry + # If copy_glyphs is true and the glyph being processed is in the same location + # (has all the same axes values) as a master UFO, + # then extract the glyph geometry directly into the target glyph. + # FYI, in the superclass method, m = buildMutator(); m.makeInstance() returns a MathGlyph + def _calculateGlyph(self, targetGlyphObject, instanceLocationObject, glyphMasters): + # Search for a glyphMaster with the same location as instanceLocationObject + found = False + if not calc_glyphs: # i.e. if copying glyphs + for item in glyphMasters: + locationObject = item['location'] # mutatorMath Location + if locationObject.sameAs(instanceLocationObject) == 0: + found = True + fontObject = item['font'] # defcon Font + glyphName = item['glyphName'] # string + glyphObject = MathGlyph(fontObject[glyphName]) + glyphObject.extractGlyph(targetGlyphObject, onlyGeometry=True) + break + + if not found: # includes case of calc_glyphs == True + super(LocalInstanceWriter, self)._calculateGlyph(targetGlyphObject, + instanceLocationObject, + glyphMasters) + + def _copyFontInfo(self, targetInfo, sourceInfo): + super(LocalInstanceWriter, self)._copyFontInfo(targetInfo, sourceInfo) + + if getattr(self, 'fixWeight', False): + # fixWeight is True since the --weightfix (or -W) option was specified + + # This mode is used for RIBBI font builds, + # therefore the weight class can be determined + # by the style name + if self.font.info.styleMapStyleName.lower().startswith("bold"): + weight_class = 700 + else: + weight_class = 400 + else: + # fixWeight is False (or None) + + # This mode is used for non-RIBBI font builds, + # therefore the weight class can be determined + # by the weight axis map in the Designspace file + foundmap = False + weight = int(self.locationObject["weight"]) + for map_space in self.axes["weight"]["map"]: + userspace = int(map_space[0]) # called input in the Designspace file + designspace = int(map_space[1]) # called output in the Designspace file + if designspace == weight: + weight_class = userspace + foundmap = True + if not foundmap: + weight_class = 399 # Dummy value designed to look non-standard + logger.log(f'No entry in designspace axis mapping for {weight}; set to 399', 'W') + setattr(targetInfo, 'openTypeOS2WeightClass', weight_class) + + localinfo = {} + for k in (('openTypeNameManufacturer', None), + ('styleMapFamilyName', 'familyName'), + ('styleMapStyleName', 'styleName')): + localinfo[k[0]] = getattr(targetInfo, k[0], (getattr(targetInfo, k[1]) if k[1] is not None else "")) + localinfo['styleMapStyleName'] = localinfo['styleMapStyleName'].title() + localinfo['year'] = re.sub(r'^.*?([0-9]+)\s*$', r'\1', getattr(targetInfo, 'openTypeNameUniqueID')) + uniqueID = "{openTypeNameManufacturer}: {styleMapFamilyName} {styleMapStyleName} {year}".format(**localinfo) + setattr(targetInfo, 'openTypeNameUniqueID', uniqueID) + + return LocalInstanceWriter + +logger = None +severe_error = False +def progress_func(state="update", action=None, text=None, tick=0): + global severe_error + if logger: + if state == 'error': + if str(action) == 'unicodes': + logger.log("%s: %s\n%s" % (state, str(action), str(text)), 'W') + else: + logger.log("%s: %s\n%s" % (state, str(action), str(text)), 'E') + severe_error = True + else: + logger.log("%s: %s\n%s" % (state, str(action), str(text)), 'I') + +def doit(args): + global logger + logger = args.logger + + designspace_path = args.designspace_path + instance_font_name = args.instanceName + instance_attr = args.instanceAttr + instance_val = args.instanceVal + output_path_prefix = args.output + calc_glyphs = args.forceInterpolation + build_folder = args.folder + round_instances = args.roundInstances + + if instance_font_name and (instance_attr or instance_val): + args.logger.log('--instanceName is mutually exclusive with --instanceAttr or --instanceVal','S') + if (instance_attr and not instance_val) or (instance_val and not instance_attr): + args.logger.log('--instanceAttr and --instanceVal must be used together', 'S') + if (build_folder and (instance_font_name or instance_attr or instance_val + or output_path_prefix or calc_glyphs)): + args.logger.log('--folder cannot be used with options: -i, -a, -v, -o, --forceInterpolation', 'S') + + args.logger.log('Interpolating master UFOs from designspace', 'P') + if not build_folder: + if not os.path.isfile(designspace_path): + args.logger.log('A designspace file (not a folder) is required', 'S') + reader = DesignSpaceDocumentReader(designspace_path, ufoVersion=3, + roundGeometry=round_instances, + progressFunc=progress_func) + # assignment to an internal object variable is a kludge, probably should use subclassing instead + reader._instanceWriterClass = InstanceWriterCF(output_path_prefix, calc_glyphs, args.weightfix) + if calc_glyphs: + args.logger.log('Interpolating glyphs where an instance font location matches a master', 'P') + if instance_font_name or instance_attr: + key_attr = instance_attr if instance_val else 'name' + key_val = instance_val if instance_attr else instance_font_name + reader.readInstance((key_attr, key_val)) + else: + reader.readInstances() + else: + # The below uses a utility function that's part of mutatorMath + # It will accept a folder and processes all designspace files there + args.logger.log('Interpolating glyphs where an instance font location matches a master', 'P') + build_designspace(designspace_path, + outputUFOFormatVersion=3, roundGeometry=round_instances, + progressFunc=progress_func) + + if not severe_error: + args.logger.log('Done', 'P') + else: + args.logger.log('Done with severe error', 'S') + +def cmd(): execute(None, doit, argspec) +if __name__ == '__main__': cmd() + +# Future development might use: fonttools\Lib\fontTools\designspaceLib to read +# the designspace file (which is the most up-to-date approach) +# then pass that object to mutatorMath, but there's no way to do that today. + + +# For reference: +# from mutatorMath/ufo/__init__.py: +# build() is a convenience function for reading and executing a designspace file. +# documentPath: filepath to the .designspace document +# outputUFOFormatVersion: ufo format for output +# verbose: True / False for lots or no feedback [to log file] +# logPath: filepath to a log file +# progressFunc: an optional callback to report progress. +# see mutatorMath.ufo.tokenProgressFunc +# +# class DesignSpaceDocumentReader(object): +# def __init__(self, documentPath, +# ufoVersion, +# roundGeometry=False, +# verbose=False, +# logPath=None, +# progressFunc=None +# ): +# +# def readInstance(self, key, makeGlyphs=True, makeKerning=True, makeInfo=True): +# def readInstances(self, makeGlyphs=True, makeKerning=True, makeInfo=True): diff --git a/src/silfont/scripts/psfcsv2comp.py b/src/silfont/scripts/psfcsv2comp.py new file mode 100644 index 0000000..5ce2f76 --- /dev/null +++ b/src/silfont/scripts/psfcsv2comp.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +__doc__ = '''generate composite definitions from csv file''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Bob Hallissy' + +import re +from silfont.core import execute +import re + +argspec = [ + ('output',{'help': 'Output file containing composite definitions'}, {'type': 'outfile'}), + ('-i','--input',{'help': 'Glyph info csv file'}, {'type': 'incsv', 'def': 'glyph_data.csv'}), + ('-f','--fontcode',{'help': 'letter to filter for glyph_data'},{}), + ('--gname', {'help': 'Column header for glyph name', 'default': 'glyph_name'}, {}), + ('--base', {'help': 'Column header for name of base', 'default': 'base'}, {}), + ('--usv', {'help': 'Column header for USV'}, {}), + ('--anchors', {'help': 'Column header(s) for APs to compose', 'default': 'above,below'}, {}), + ('-r','--report',{'help': 'Set reporting level for log', 'type':str, 'choices':['X','S','E','P','W','I','V']},{}), + ('-l','--log',{'help': 'Set log file name'}, {'type': 'outfile', 'def': 'csv2comp.log'}), + ] + +def doit(args): + logger = args.logger + if args.report: logger.loglevel = args.report + # infont = args.ifont + incsv = args.input + output = args.output + + def csvWarning(msg, exception = None): + m = "glyph_data warning: %s at line %d" % (msg, incsv.line_num) + if exception is not None: + m += '; ' + exception.message + logger.log(m, 'W') + + if args.fontcode is not None: + whichfont = args.fontcode.strip().lower() + if len(whichfont) != 1: + logger.log('-f parameter must be a single letter', 'S') + else: + whichfont = None + + # Which headers represent APs to use: + apList = args.anchors.split(',') + if len(apList) == 0: + logger.log('--anchors option value "%s" is invalid' % args.anchors, 'S') + + # Get headings from csvfile: + fl = incsv.firstline + if fl is None: logger.log("Empty input file", "S") + # required columns: + try: + nameCol = fl.index(args.gname) + baseCol = fl.index(args.base) + apCols = [fl.index(ap) for ap in apList] + if args.usv is not None: + usvCol = fl.index(args.usv) + else: + usvCol = None + except ValueError as e: + logger.log('Missing csv input field: ' + e.message, 'S') + except Exception as e: + logger.log('Error reading csv input field: ' + e.message, 'S') + + # Now make strip AP names; pair up with columns so easy to iterate: + apInfo = list(zip(apCols, [x.strip() for x in apList])) + + # If -f specified, make sure we have the fonts column + if whichfont is not None: + if 'fonts' not in fl: logger.log('-f requires "fonts" column in glyph_data', 'S') + fontsCol = fl.index('fonts') + + # RE that matches names of glyphs we don't care about + namesToSkipRE = re.compile('^(?:[._].*|null|cr|nonmarkingreturn|tab|glyph_name)$',re.IGNORECASE) + + # keep track of glyph names we've seen to detect duplicates + namesSeen = set() + + # OK, process all records in glyph_data + for line in incsv: + base = line[baseCol].strip() + if len(base) == 0: + # No composites specified + continue + + gname = line[nameCol].strip() + # things to ignore: + if namesToSkipRE.match(gname): continue + if whichfont is not None and line[fontsCol] != '*' and line[fontsCol].lower().find(whichfont) < 0: + continue + + if len(gname) == 0: + csvWarning('empty glyph name in glyph_data; ignored') + continue + if gname.startswith('#'): continue + if gname in namesSeen: + csvWarning('glyph name %s previously seen in glyph_data; ignored' % gname) + continue + namesSeen.add(gname) + + # Ok, start building the composite + composite = '%s = %s' %(gname, base) + + # The first component must *not* reference the base; all others *must*: + seenfirst = False + for apCol, apName in apInfo: + component = line[apCol].strip() + if len(component): + if not seenfirst: + composite += ' + %s@%s' % (component, apName) + seenfirst = True + else: + composite += ' + %s@%s:%s' % (component, base, apName) + + # Add USV if present + if usvCol is not None: + usv = line[usvCol].strip() + if len(usv): + composite += ' | %s' % usv + + # Output this one + output.write(composite + '\n') + + output.close() + +def cmd() : execute("",doit,argspec) +if __name__ == "__main__": cmd() + diff --git a/src/silfont/scripts/psfdeflang.py b/src/silfont/scripts/psfdeflang.py new file mode 100644 index 0000000..ba41ae7 --- /dev/null +++ b/src/silfont/scripts/psfdeflang.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +__doc__ = '''Switch default language in a font''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Martin Hosken' + +from silfont.core import execute + +argspec = [ + ('ifont',{'help': 'Input TTF'}, {'type': 'infont'}), + ('ofont',{'help': 'Output TTF','nargs': '?' }, {'type': 'outfont'}), + ('-L','--lang', {'help': 'Language to switch to'}, {}), + ('-l','--log',{'help': 'Optional log file'}, {'type': 'outfile', 'def': '_deflang.log', 'optlog': True}), +] + +def long2tag(x): + res = [] + while x: + res.append(chr(x & 0xFF)) + x >>= 8 + return "".join(reversed(res)) + +def doit(args): + infont = args.ifont + ltag = args.lang.lower() + if 'Sill' in infont and 'Feat' in infont: + if ltag in infont['Sill'].langs: + changes = dict((long2tag(x[0]), x[1]) for x in infont['Sill'].langs[ltag]) + for g, f in infont['Feat'].features.items(): + if g in changes: + f.default = changes[g] + otltag = ltag + (" " * (4 - len(ltag))) + for k in ('GSUB', 'GPOS'): + try: + t = infont[k].table + except KeyError: + continue + for srec in t.ScriptList.ScriptRecord: + for lrec in srec.Script.LangSysRecord: + if lrec.LangSysTag.lower() == otltag: + srec.Script.DefaultLangSys = lrec.LangSys + return infont + +def cmd() : execute('FT', doit, argspec) +if __name__ == "__main__" : cmd() + diff --git a/src/silfont/scripts/psfdeleteglyphs.py b/src/silfont/scripts/psfdeleteglyphs.py new file mode 100644 index 0000000..1f32b17 --- /dev/null +++ b/src/silfont/scripts/psfdeleteglyphs.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +__doc__ = '''Deletes glyphs from a UFO based on list. Can instead delete glyphs not in list.''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Victor Gaultney' + +from silfont.core import execute +from xml.etree import ElementTree as ET + +argspec = [ + ('ifont', {'help': 'Input font file'}, {'type': 'infont'}), + ('ofont', {'help': 'Output font file', 'nargs': '?'}, {'type': 'outfont'}), + ('-i', '--input', {'help': 'Input text file, one glyphname per line'}, {'type': 'infile', 'def': 'glyphlist.txt'}), + ('--reverse',{'help': 'Remove glyphs not in list instead', 'action': 'store_true', 'default': False},{}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': 'deletedglyphs.log'})] + +def doit(args) : + font = args.ifont + listinput = args.input + logger = args.logger + + glyphlist = [] + for line in listinput.readlines(): + glyphlist.append(line.strip()) + + deletelist = [] + + if args.reverse: + for glyphname in font.deflayer: + if glyphname not in glyphlist: + deletelist.append(glyphname) + else: + for glyphname in font.deflayer: + if glyphname in glyphlist: + deletelist.append(glyphname) + + secondarylayers = [x for x in font.layers if x.layername != "public.default"] + + liststocheck = ('public.glyphOrder', 'public.postscriptNames', 'com.schriftgestaltung.glyphOrder') + liblists = [[],[],[]]; inliblists = [[],[],[]] + if hasattr(font, 'lib'): + for (i,listn) in enumerate(liststocheck): + if listn in font.lib: + liblists[i] = font.lib.getval(listn) + else: + logger.log("No lib.plist found in font", "W") + + # Now loop round deleting the glyphs etc + logger.log("Deleted glyphs:", "I") + + # With groups and kerning, create dicts representing then plists (to make deletion of members easier) and indexes by glyph/member name + kgroupprefixes = {"public.kern1.": 1, "public.kern2.": 2} + gdict = {} + kdict = {} + groupsbyglyph = {} + ksetsbymember = {} + + groups = font.groups if hasattr(font, "groups") else [] + kerning = font.kerning if hasattr(font, "kerning") else [] + if groups: + for gname in groups: + group = groups.getval(gname) + gdict[gname] = group + for glyph in group: + if glyph in groupsbyglyph: + groupsbyglyph[glyph].append(gname) + else: + groupsbyglyph[glyph] = [gname] + if kerning: + for setname in kerning: + kset = kerning.getval(setname) + kdict[setname] = kset + for member in kset: + if member in ksetsbymember: + ksetsbymember[member].append(setname) + else: + ksetsbymember[member] = [setname] + + # Loop round doing the deleting + for glyphn in sorted(deletelist): + # Delete from all layers + font.deflayer.delGlyph(glyphn) + deletedfrom = "Default layer" + for layer in secondarylayers: + if glyphn in layer: + deletedfrom += ", " + layer.layername + layer.delGlyph(glyphn) + # Check to see if the deleted glyph is in any of liststocheck + stillin = None + for (i, liblist) in enumerate(liblists): + if glyphn in liblist: + inliblists[i].append(glyphn) + stillin = stillin + ", " + liststocheck[i] if stillin else liststocheck[i] + + logger.log(" " + glyphn + " deleted from: " + deletedfrom, "I") + if stillin: logger.log(" " + glyphn + " is still in " + stillin, "I") + + # Process groups.plist and kerning.plist + + tocheck = (glyphn, "public.kern1." + glyphn, "public.kern2." + glyphn) + # First delete whole groups and kern pair sets + for kerngroup in tocheck[1:]: # Don't check glyphn when deleting groups: + if kerngroup in gdict: gdict.pop(kerngroup) + for setn in tocheck: + if setn in kdict: kdict.pop(setn) + # Now delete members within groups and kern pair sets + if glyphn in groupsbyglyph: + for groupn in groupsbyglyph[glyphn]: + if groupn in gdict: # Need to check still there, since whole group may have been deleted above + group = gdict[groupn] + del group[group.index(glyphn)] + for member in tocheck: + if member in ksetsbymember: + for setn in ksetsbymember[member]: + if setn in kdict: del kdict[setn][member] + # Now need to recreate groups.plist and kerning.plist + if groups: + for group in list(groups): groups.remove(group) # Empty existing contents + for gname in gdict: + elem = ET.Element("array") + if gdict[gname]: # Only create if group is not empty + for glyph in gdict[gname]: + ET.SubElement(elem, "string").text = glyph + groups.setelem(gname, elem) + if kerning: + for kset in list(kerning): kerning.remove(kset) # Empty existing contents + for kset in kdict: + elem = ET.Element("dict") + if kdict[kset]: + for member in kdict[kset]: + ET.SubElement(elem, "key").text = member + ET.SubElement(elem, "integer").text = str(kdict[kset][member]) + kerning.setelem(kset, elem) + + logger.log(str(len(deletelist)) + " glyphs deleted. Set logging to I to see details", "P") + inalist = set(inliblists[0] + inliblists[1] + inliblists[2]) + if inalist: logger.log(str(len(inalist)) + " of the deleted glyphs are still in some lib.plist entries.", "W") + + return font + +def cmd() : execute("UFO",doit,argspec) +if __name__ == "__main__": cmd() + diff --git a/src/silfont/scripts/psfdupglyphs.py b/src/silfont/scripts/psfdupglyphs.py new file mode 100644 index 0000000..142071e --- /dev/null +++ b/src/silfont/scripts/psfdupglyphs.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +'''Duplicates glyphs in a UFO based on a csv definition: source,target. +Duplicates everything except unicodes.''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Victor Gaultney' + +from silfont.core import execute + +argspec = [ + ('ifont', {'help': 'Input font filename'}, {'type': 'infont'}), + ('ofont',{'help': 'Output font file','nargs': '?' }, {'type': 'outfont'}), + ('-i','--input',{'help': 'Input csv file'}, {'type': 'incsv', 'def': 'duplicates.csv'}), + ('-l','--log',{'help': 'Set log file name'}, {'type': 'outfile', 'def': '_duplicates.log'})] + +def doit(args) : + font = args.ifont + logger = args.logger + + # Process duplicates csv file into a dictionary structure + args.input.numfields = 2 + duplicates = {} + for line in args.input : + duplicates[line[0]] = line[1] + + # Iterate through dictionary (unsorted) + for source, target in duplicates.items() : + # Check if source glyph is in font + if source in font.keys() : + # Give warning if target is already in font, but overwrite anyway + if target in font.keys() : + logger.log("Warning: " + target + " already in font and will be replaced") + sourceglyph = font[source] + # Make a copy of source into a new glyph object + newglyph = sourceglyph.copy() + # Modify that glyph object + newglyph.unicodes = [] + # Add the new glyph object to the font with name target + font.__setitem__(target,newglyph) + logger.log(source + " duplicated to " + target) + else : + logger.log("Warning: " + source + " not in font") + + return font + +def cmd() : execute("FP",doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfexportanchors.py b/src/silfont/scripts/psfexportanchors.py new file mode 100644 index 0000000..a51aed8 --- /dev/null +++ b/src/silfont/scripts/psfexportanchors.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +__doc__ = 'export anchor data from UFO to XML file' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015,2016 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Rowe' + +from silfont.core import execute +from silfont.etutil import ETWriter +from xml.etree import ElementTree as ET + +argspec = [ + ('ifont',{'help': 'Input UFO'}, {'type': 'infont'}), + ('output',{'help': 'Output file exported anchor data in XML format', 'nargs': '?'}, {'type': 'outfile', 'def': '_anc.xml'}), + ('-r','--report',{'help': 'Set reporting level for log', 'type':str, 'choices':['X','S','E','P','W','I','V']},{}), + ('-l','--log',{'help': 'Set log file name'}, {'type': 'outfile', 'def': '_anc.log'}), + ('-g','--gid',{'help': 'Include GID attribute in <glyph> elements', 'action': 'store_true'},{}), + ('-s','--sort',{'help': 'Sort by public.glyphOrder in lib.plist', 'action': 'store_true'},{}), + ('-u','--Uprefix',{'help': 'Include U+ prefix on UID attribute in <glyph> elements', 'action': 'store_true'},{}), + ('-p','--params',{'help': 'XML formatting parameters: indentFirst, indentIncr, attOrder','action': 'append'}, {'type': 'optiondict'}) + ] + +def doit(args) : + logfile = args.logger + if args.report: logfile.loglevel = args.report + infont = args.ifont + prefix = "U+" if args.Uprefix else "" + + if hasattr(infont, 'lib') and 'public.glyphOrder' in infont.lib: + glyphorderlist = [s.text for s in infont.lib['public.glyphOrder'][1].findall('string')] + else: + glyphorderlist = [] + if args.gid: + logfile.log("public.glyphOrder is absent; ignoring --gid option", "E") + args.gid = False + glyphorderset = set(glyphorderlist) + if len(glyphorderlist) != len(glyphorderset): + logfile.log("At least one duplicate name in public.glyphOrder", "W") + # count of duplicate names is len(glyphorderlist) - len(glyphorderset) + actualglyphlist = [g for g in infont.deflayer.keys()] + actualglyphset = set(actualglyphlist) + listorder = [] + gid = 0 + for g in glyphorderlist: + if g in actualglyphset: + listorder.append( (g, gid) ) + gid += 1 + actualglyphset.remove(g) + glyphorderset.remove(g) + else: + logfile.log(g + " in public.glyphOrder list but absent from UFO", "W") + if args.sort: listorder.sort() + for g in sorted(actualglyphset): # if any glyphs remaining + listorder.append( (g, None) ) + logfile.log(g + " in UFO but not in public.glyphOrder list", "W") + + if 'postscriptFontName' in infont.fontinfo: + postscriptFontName = infont.fontinfo['postscriptFontName'][1].text + else: + if 'styleMapFamilyName' in infont.fontinfo: + family = infont.fontinfo['styleMapFamilyName'][1].text + elif 'familyName' in infont.fontinfo: + family = infont.fontinfo['familyName'][1].text + else: + family = "UnknownFamily" + if 'styleMapStyleName' in infont.fontinfo: + style = infont.fontinfo['styleMapStyleName'][1].text.capitalize() + elif 'styleName' in infont.fontinfo: + style = infont.fontinfo['styleName'][1].text + else: + style = "UnknownStyle" + + postscriptFontName = '-'.join((family,style)).replace(' ','') + fontElement= ET.Element('font', upem=infont.fontinfo['unitsPerEm'][1].text, name=postscriptFontName) + for g, i in listorder: + attrib = {'PSName': g} + if args.gid and i != None: attrib['GID'] = str(i) + u = infont.deflayer[g]['unicode'] + if len(u)>0: attrib['UID'] = prefix + u[0].element.get('hex') + glyphElement = ET.SubElement(fontElement, 'glyph', attrib) + anchorlist = [] + for a in infont.deflayer[g]['anchor']: + anchorlist.append( (a.element.get('name'), int(float(a.element.get('x'))), int(float(a.element.get('y'))) ) ) + anchorlist.sort() + for a, x, y in anchorlist: + anchorElement = ET.SubElement(glyphElement, 'point', attrib = {'type': a}) + locationElement = ET.SubElement(anchorElement, 'location', attrib = {'x': str(x), 'y': str(y)}) + +# instead of simple serialization with: ofile.write(ET.tostring(fontElement)) +# create ETWriter object and specify indentation and attribute order to get normalized output + ofile = args.output + indentFirst = args.params.get('indentFirst', "") + indentIncr = args.params.get('indentIncr', " ") + attOrder = args.params.get('attOrder', "name,upem,PSName,GID,UID,type,x,y") + x = attOrder.split(',') + attributeOrder = dict(zip(x,range(len(x)))) + etwobj=ETWriter(fontElement, indentFirst=indentFirst, indentIncr=indentIncr, attributeOrder=attributeOrder) + ofile.write(etwobj.serialize_xml()) + +def cmd() : execute("UFO",doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfexportmarkcolors.py b/src/silfont/scripts/psfexportmarkcolors.py new file mode 100644 index 0000000..e79be38 --- /dev/null +++ b/src/silfont/scripts/psfexportmarkcolors.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +__doc__ = '''Write mapping of glyph name to cell mark color to a csv file +- csv format glyphname,colordef''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Victor Gaultney' + +from silfont.core import execute +from silfont.util import parsecolors, colortoname +import datetime + +suffix = "_colormap" +argspec = [ + ('ifont',{'help': 'Input font file'}, {'type': 'infont'}), + ('-o','--output',{'help': 'Output csv file'}, {'type': 'outfile', 'def': suffix+'.csv'}), + ('-c','--color',{'help': 'Export list of glyphs that match color'},{}), + ('-n','--names',{'help': 'Export colors as names', 'action': 'store_true', 'default': False},{}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': suffix+'.log'}), + ('--nocomments',{'help': 'No comments in output files', 'action': 'store_true', 'default': False},{})] + +def doit(args) : + font = args.ifont + outfile = args.output + logger = args.logger + color = args.color + + # Add initial comments to outfile + if not args.nocomments : + outfile.write("# " + datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S ") + args.cmdlineargs[0] + "\n") + outfile.write("# "+" ".join(args.cmdlineargs[1:])+"\n\n") + + if color : + (colorfilter, colorname, logcolor, splitcolor) = parsecolors(color, single=True) + if colorfilter is None : logger.log(logcolor, "S") # If color not parsed, parsecolors() puts error in logcolor + + glyphlist = font.deflayer.keys() + + for glyphn in sorted(glyphlist) : + glyph = font.deflayer[glyphn] + colordefraw = "" + colordef = "" + if glyph["lib"] : + lib = glyph["lib"] + if "public.markColor" in lib : + colordefraw = lib["public.markColor"][1].text + colordef = '"' + colordefraw + '"' + if args.names : colordef = colortoname(colordefraw, colordef) + if color : + if colorfilter == colordefraw : outfile.write(glyphn + "\n") + if not color : outfile.write(glyphn + "," + colordef + "\n") + return + +def cmd() : execute("UFO",doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfexportpsnames.py b/src/silfont/scripts/psfexportpsnames.py new file mode 100644 index 0000000..688e696 --- /dev/null +++ b/src/silfont/scripts/psfexportpsnames.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +__doc__ = '''Write mapping of glyph name to postscript name to a csv file +- csv format glyphname,postscriptname''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2016 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute +import datetime + +suffix = "_psnamesmap" +argspec = [ + ('ifont',{'help': 'Input font file'}, {'type': 'infont'}), + ('-o','--output',{'help': 'Ouput csv file'}, {'type': 'outfile', 'def': suffix+'.csv'}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': suffix+'.log'}), + ('--nocomments',{'help': 'No comments in output files', 'action': 'store_true', 'default': False},{})] + +def doit(args) : + font = args.ifont + outfile = args.output + + # Add initial comments to outfile + if not args.nocomments : + outfile.write("# " + datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S ") + args.cmdlineargs[0] + "\n") + outfile.write("# "+" ".join(args.cmdlineargs[1:])+"\n\n") + + glyphlist = font.deflayer.keys() + missingnames = False + + for glyphn in glyphlist : + glyph = font.deflayer[glyphn] + # Find PSname if present + PSname = None + if "lib" in glyph : + lib = glyph["lib"] + if "public.postscriptname" in lib : PSname = lib["public.postscriptname"][1].text + if PSname: + outfile.write(glyphn + "," + PSname + "\n") + else : + font.logger("No psname for " + glyphn, "W") + missingnames = True + if missingnames : font.logger("Some glyphs had no psnames - see log file","E") + return + +def cmd() : execute("UFO",doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfexportunicodes.py b/src/silfont/scripts/psfexportunicodes.py new file mode 100644 index 0000000..50c48b8 --- /dev/null +++ b/src/silfont/scripts/psfexportunicodes.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +__doc__ = '''Export the name and unicode of glyphs that have a defined unicode to a csv file. Does not support double-encoded glyphs. +- csv format glyphname,unicode''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2016-2020 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Victor Gaultney, based on UFOexportPSname.py' + +from silfont.core import execute +import datetime + +suffix = "_unicodes" +argspec = [ + ('ifont',{'help': 'Input font file'}, {'type': 'infont'}), + ('-o','--output',{'help': 'Output csv file'}, {'type': 'outfile', 'def': suffix+'.csv'}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': suffix+'.log'}), + ('--nocomments',{'help': 'No comments in output files', 'action': 'store_true', 'default': False},{}), + ('--allglyphs',{'help': 'Export names of all glyphs even without', 'action': 'store_true', 'default': False},{})] + +def doit(args) : + font = args.ifont + outfile = args.output + + # Add initial comments to outfile + if not args.nocomments : + outfile.write("# " + datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S ") + args.cmdlineargs[0] + "\n") + outfile.write("# "+" ".join(args.cmdlineargs[1:])+"\n\n") + + glyphlist = sorted(font.deflayer.keys()) + + for glyphn in glyphlist : + glyph = font.deflayer[glyphn] + if len(glyph["unicode"]) == 1 : + unival = glyph["unicode"][0].hex + outfile.write(glyphn + "," + unival + "\n") + else : + if args.allglyphs : + outfile.write(glyphn + "," + "\n") + + return + +def cmd() : execute("UFO",doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psffixffglifs.py b/src/silfont/scripts/psffixffglifs.py new file mode 100644 index 0000000..58b9759 --- /dev/null +++ b/src/silfont/scripts/psffixffglifs.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +__doc__ = '''Make changes needed to a UFO following processing by FontForge. +''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute + +argspec = [ + ('ifont',{'help': 'Input font file'}, {'type': 'infont'}), + ('ofont',{'help': 'Output font file','nargs': '?' }, {'type': 'outfont'}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': '_postff.log'})] + +def doit(args) : + + font = args.ifont + logger = args.logger + + advances_removed = 0 + unicodes_removed = 0 + for layer in font.layers: + if layer.layername == "public.background": + for g in layer: + glyph = layer[g] + # Remove advance and unicode fields from background layer + # (FF currently copies some from default layer) + if "advance" in glyph: + glyph.remove("advance") + advances_removed += 1 + logger.log("Removed <advance> from " + g, "I") + uc = glyph["unicode"] + if uc != []: + while glyph["unicode"] != []: glyph.remove("unicode",0) + unicodes_removed += 1 + logger.log("Removed unicode value(s) from " + g, "I") + + if advances_removed + unicodes_removed > 0 : + logger.log("Advance removed from " + str(advances_removed) + " glyphs and unicode values(s) removed from " + + str(unicodes_removed) + " glyphs", "P") + else: + logger.log("No advances or unicodes removed from glyphs", "P") + + return args.ifont + +def cmd() : execute("UFO",doit, argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psffixfontlab.py b/src/silfont/scripts/psffixfontlab.py new file mode 100644 index 0000000..70c823e --- /dev/null +++ b/src/silfont/scripts/psffixfontlab.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +__doc__ = '''Make changes needed to a UFO following processing by FontLab 7. +Various items are reset using the backup of the original font that Fontlab creates +''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2021 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute, splitfn +from silfont.ufo import Ufont +import os, shutil, glob + +argspec = [ + ('ifont',{'help': 'Input font file'}, {'type': 'filename'}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': '_fixfontlab.log'})] + +def doit(args) : + + fontname = args.ifont + logger = args.logger + params = args.paramsobj + + # Locate the oldest backup + (path, base, ext) = splitfn(fontname) + backuppath = os.path.join(path, base + ".*-*" + ext) # Backup has date/time added in format .yymmdd-hhmm + backups = glob.glob(backuppath) + if len(backups) == 0: + logger.log("No backups found matching %s so no changes made to the font" % backuppath, "P") + return + backupname = sorted(backups)[0] # Choose the oldest backup - date/time format sorts alphabetically + + # Reset groups.plist, kerning.plist and any layerinfo.plist(s) from backup ufo + for filename in ["groups.plist", "kerning.plist"]: + bufullname = os.path.join(backupname, filename) + ufofullname = os.path.join(fontname, filename) + if os.path.exists(bufullname): + try: + shutil.copy(bufullname, fontname) + logger.log(filename + " restored from backup", "P") + except Exception as e: + logger.log("Failed to copy %s to %s: %s" % (bufullname, fontname, str(e)), "S") + elif os.path.exists(ufofullname): + os.remove(ufofullname) + logger.log(filename + " removed from ufo", "P") + lifolders = [] + for ufoname in (fontname, backupname): # Find any layerinfo files in either ufo + lis = glob.glob(os.path.join(ufoname, "*/layerinfo.plist")) + for li in lis: + (lifolder, dummy) = os.path.split(li) # Get full path name for folder + (dummy, lifolder) = os.path.split(lifolder) # Now take ufo name off the front + if lifolder not in lifolders: lifolders.append(lifolder) + for folder in lifolders: + filename = os.path.join(folder, "layerinfo.plist") + bufullname = os.path.join(backupname, filename) + ufofullname = os.path.join(fontname, filename) + if os.path.exists(bufullname): + try: + shutil.copy(bufullname, os.path.join(fontname, folder)) + logger.log(filename + " restored from backup", "P") + except Exception as e: + logger.log("Failed to copy %s to %s: %s" % (bufullname, fontname, str(e)), "S") + elif os.path.exists(ufofullname): + os.remove(ufofullname) + logger.log(filename + " removed from ufo", "P") + + # Now open the fonts + font = Ufont(fontname, params = params) + backupfont = Ufont(backupname, params = params) + + fidel = ("openTypeGaspRangeRecords", "openTypeHheaCaretOffset", + "postscriptBlueFuzz", "postscriptBlueScale", "postscriptBlueShift", "postscriptForceBold", + "postscriptIsFixedPitch", "postscriptWeightName") + libdel = ("com.fontlab.v2.tth", "com.typemytype.robofont.italicSlantOffset") + fontinfo = font.fontinfo + libplist = font.lib + backupfi = backupfont.fontinfo + backuplib = backupfont.lib + + # Delete keys that are not needed + for key in fidel: + if key in fontinfo: + old = fontinfo.getval(key) + fontinfo.remove(key) + logchange(logger, " removed from fontinfo.plist. ", key, old, None) + for key in libdel: + if key in libplist: + old = libplist.getval(key) + libplist.remove(key) + logchange(logger, " removed from lib.plist. ", key, old, None) + + # Correct other metadata: + if "guidelines" in backupfi: + fontinfo.setelem("guidelines",backupfi["guidelines"][1]) + logger.log("fontinfo guidelines copied from backup ufo", "I") + elif "guidelines" in fontinfo: + fontinfo.remove("guidelines") + logger.log("fontinfo guidelines deleted - not in backup ufo", "I") + if "italicAngle" in fontinfo and fontinfo.getval("italicAngle") == 0: + fontinfo.remove("italicAngle") + logger.log("fontinfo italicAngle removed since it was 0", "I") + if "openTypeOS2VendorID" in fontinfo: + old = fontinfo.getval("openTypeOS2VendorID") + if len(old) < 4: + new = "%-4s" % (old,) + fontinfo.setval("openTypeOS2VendorID", "string", new) + logchange(logger, " padded to 4 characters ", "openTypeOS2VendorID", "'%s'" % (old,) , "'%s'" % (new,)) + if "woffMetadataCredits" in backupfi: + fontinfo.setelem("woffMetadataCredits",backupfi["woffMetadataCredits"][1]) + logger.log("fontinfo woffMetadataCredits copied from backup ufo", "I") + elif "woffMetadataCredits" in fontinfo: + fontinfo.remove("woffMetadataCredits") + logger.log("fontinfo woffMetadataCredits deleted - not in backup ufo", "I") + if "woffMetadataDescription" in backupfi: + fontinfo.setelem("woffMetadataDescription",backupfi["woffMetadataDescription"][1]) + logger.log("fontinfo woffMetadataDescription copied from backup ufo", "I") + elif "woffMetadataDescription" in fontinfo: + fontinfo.remove("woffMetadataDescription") + logger.log("fontinfo woffMetadataDescription deleted - not in backup ufo", "I") + if "public.glyphOrder" in backuplib: + libplist.setelem("public.glyphOrder",backuplib["public.glyphOrder"][1]) + logger.log("lib.plist public.glyphOrder copied from backup ufo", "I") + elif "public.glyphOrder" in libplist: + libplist.remove("public.glyphOrder") + logger.log("libplist public.glyphOrder deleted - not in backup ufo", "I") + + + + # Now process glif level data + updates = False + for gname in font.deflayer: + glyph = font.deflayer[gname] + glines = glyph["guideline"] + if glines: + for gl in list(glines): glines.remove(gl) # Remove any existing glines + updates = True + buglines = backupfont.deflayer[gname]["guideline"] if gname in backupfont.deflayer else [] + if buglines: + for gl in buglines: glines.append(gl) # Add in those from backup + updates = True + if updates: + logger.log("Some updates to glif guidelines may have been made", "I") + updates = False + for layer in font.layers: + if layer.layername == "public.background": + for gname in layer: + glyph = layer[gname] + if glyph["advance"] is not None: + glyph.remove("advance") + updates = True + if updates: logger.log("Some advance elements removed from public.background glifs", "I") + font.write(fontname) + return + +def logchange(logger, logmess, key, old, new): + oldstr = str(old) if len(str(old)) < 22 else str(old)[0:20] + "..." + newstr = str(new) if len(str(new)) < 22 else str(new)[0:20] + "..." + logmess = key + logmess + if old is None: + logmess = logmess + " New value: " + newstr + else: + if new is None: + logmess = logmess + " Old value: " + oldstr + else: + logmess = logmess + " Old value: " + oldstr + ", new value: " + newstr + logger.log(logmess, "I") + +def cmd() : execute(None,doit, argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfftml2TThtml.py b/src/silfont/scripts/psfftml2TThtml.py new file mode 100644 index 0000000..097506e --- /dev/null +++ b/src/silfont/scripts/psfftml2TThtml.py @@ -0,0 +1,389 @@ +#! /usr/bin/env python3 +'''Build fonts for all combinations of TypeTuner features needed for specific ftml then build html that uses those fonts''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Bob Hallissy' + +from silfont.core import execute +from fontTools import ttLib +from lxml import etree as ET # using this because it supports xslt and HTML +from collections import OrderedDict +from subprocess import check_output, CalledProcessError +import os, re +import gzip +from glob import glob + + +argspec = [ + ('ttfont', {'help': 'Input Tunable TTF file'}, {'type': 'filename'}), + ('map', {'help': 'Feature mapping CSV file'}, {'type': 'incsv'}), + ('-o', '--outputdir', {'help': 'Output directory. Default: tests/typetuner', 'default': 'tests/typetuner'}, {}), + ('--ftml', {'help': 'ftml file(s) to process. Can be used multiple times and can contain filename patterns.', 'action': 'append'}, {}), + ('--xsl', {'help': 'standard xsl file. Default: ../tools/ftml.xsl', 'default': '../tools/ftml.xsl'}, {'type': 'filename'}), + ('--norebuild', {'help': 'assume existing fonts are good', 'action': 'store_true'}, {}), + ] + +# Define globals needed everywhere: + +logger = None +sourcettf = None +outputdir = None +fontdir = None + + +# Dictionary of TypeTuner features, derived from 'feat_all.xml', indexed by feature name +feat_all = dict() + +class feat(object): + 'TypeTuner feature' + def __init__(self, elem, sortkey): + self.name = elem.attrib.get('name') + self.tag = elem.attrib.get('tag') + self.default = elem.attrib.get('value') + self.values = OrderedDict() + self.sortkey = sortkey + for v in elem.findall('value'): + # Only add those values which aren't importing line metrics + if v.find("./cmd[@name='line_metrics_scaled']") is None: + self.values[v.attrib.get('name')] = v.attrib.get('tag') + + +# Dictionaries of mappings from OpenType tags to TypeTuner names, derived from map csv +feat_maps = dict() +lang_maps = dict() + +class feat_map(object): + 'mapping from OpenType feature tag to TypeTuner feature name, default value, and all values' + def __init__(self, r): + self.ottag, self.ttfeature, self.default = r[0:3] + self.ttvalues = r[3:] + +class lang_map(object): + 'mapping from OpenType language tag to TypeTuner language feature name and value' + def __init__(self,r): + self.ottag, self.ttfeature, self.ttvalue = r + +# About font_tag values +# +# In this code, a font_tag uniquely identifies a font we've built. +# +# Because different ftml files could use different style names for the same set of features and language, and we +# want to build only one font for any given combination of features and language, we don't depend on the name of the +# ftml style for identifying and caching the fonts we build. Rather we build a font_tag which is a the +# concatenation of the ftml feature/value tags and the ftml lang feature/value tag. + +# Font object used to cache information about a tuned font we've created + +class font(object): + 'Cache of tuned font information' + + def __init__(self, font_tag, feats, lang, fontface): + self.font_tag = font_tag + self.feats = feats + self.lang = lang + self.fontface = fontface + + +# Dictionaries for finding font objects + +# Finding font from font_tag: +font_tag2font = dict() + +# If an ftml style contains no feats, only the lang tag will show up in the html. Special mapping for those cases: +lang2font = dict() + +# RE to match strings like: # "'cv02' 1" +feature_settingRE = re.compile(r"^'(\w{4,4})'(?:\s+(\w+))?$") +# RE to split strings of multiple features around comma (with optional whitespace) +features_splitRE = re.compile(r"\s*,\s*") + + +def cache_font(feats, lang, norebuild): + 'Create (and cache) a TypeTuned font and @fontface for this combination of features and lang' + + # feats is either None or a css font-feature-settings string using single quotes (according to ftml spec), e.g. "'cv02' 1, 'cv60' 1" + # lang is either None or bcp47 langtag + # norebuild is a debugging aid that causes the code to skip building a .ttf if it is already present thus making the + # program run faster but with the risk that the built TTFs don't match the current build. + + # First step is to construct a name for this set of languages and features, something we'll call the "font tag" + + parts = [] + ttsettings = dict() # place to save TT setting name and value in case we need to build the font + fatal_errors = False + + if feats: + # Need to split the font-feature-settings around commas and parse each part, mapping css tag and value to + # typetuner tag and value + for setting in features_splitRE.split(feats): + m = feature_settingRE.match(setting) + if m is None: + logger.log('Invalid CSS feature setting in ftml: {}'.format(setting), 'E') + fatal_errors = True + continue + f,v = m.groups() # Feature tag and value + if v in ['normal','off']: + v = '0' + elif v == 'on': + v = '1' + try: + v = int(v) + assert v >= 0 + except: + logger.log('Invalid feature value {} found in map file'.format(setting), 'E') + fatal_errors = True + continue + if not v: + continue # No need to include 0/off values + + # we need this one... so translate to TypeTuner feature & value using the map file + try: + fmap = feat_maps[f] + except KeyError: + logger.log('Font feature "{}" not found in map file'.format(f), 'E') + fatal_errors = True + continue + + f = fmap.ttfeature + + try: + v = fmap.ttvalues[v - 1] + except IndexError: + logger.log('TypeTuner feature "{}" doesn\'t have a value index {}'.format(f, v), 'E') + fatal_errors = True + continue + + # Now translate to TypeTuner tags using feat_all info + if f not in feat_all: + logger.log('Tunable font doesn\'t contain a feature "{}"'.format(f), 'E') + fatal_errors = True + elif v not in feat_all[f].values: + logger.log('Tunable font feature "{}" doesn\'t have a value {}'.format(f, v), 'E') + fatal_errors = True + else: + ttsettings[f] = v # Save TT setting name and value name in case we need to build the font + ttfeat = feat_all[f] + f = ttfeat.tag + v = ttfeat.values[v] + # Finally! + parts.append(f+v) + if lang: + if lang not in lang_maps: + logger.log('Language tag "{}" not found in map file'.format(lang), 'E') + fatal_errors = True + else: + # Translate to TypeTuner feature & value using the map file + lmap = lang_maps[lang] + f = lmap.ttfeature + v = lmap.ttvalue + # Translate to TypeTuner tags using feat_all info + if f not in feat_all: + logger.log('Tunable font doesn\'t contain a feature "{}"'.format(f), 'E') + fatal_errors = True + elif v not in feat_all[f].values: + logger.log('Tunable font feature "{}" doesn\'t have a value {}'.format(f, v), 'E') + fatal_errors = True + else: + ttsettings[f] = v # Save TT setting name and value in case we need to build the font + ttfeat = feat_all[f] + f = ttfeat.tag + v = ttfeat.values[v] + # Finally! + parts.append(f+v) + if fatal_errors: + return None + + if len(parts) == 0: + logger.log('No features or languages found'.format(f), 'E') + return None + + # the Font Tag is how we name everything (the ttf, the xml, etc) + font_tag = '_'.join(sorted(parts)) + + # See if we've had this combination before: + if font_tag in font_tag2font: + logger.log('Found cached font {}'.format(font_tag), 'I') + return font_tag + + # Path to font, which may already exist, and @fontface + ttfname = os.path.join(fontdir, font_tag + '.ttf') + fontface = '@font-face { font-family: {}; src: url(fonts/{}.ttf); } .{} {font-family: {}; }'.replace('{}',font_tag) + + # Create new font object and remember how to find it: + thisfont = font(font_tag, feats, lang, fontface) + font_tag2font[font_tag] = thisfont + if lang and not feats: + lang2font[lang] = thisfont + + # Debugging shortcut: use the existing fonts without rebuilding + if norebuild and os.path.isfile(ttfname): + logger.log('Blindly using existing font {}'.format(font_tag), 'I') + return font_tag + + # Ok, need to build the font + logger.log('Building font {}'.format(font_tag), 'I') + + # Create and save the TypeTuner feature settings file + sfname = os.path.join(fontdir, font_tag + '.xml') + root = ET.XML('''\ +<?xml version = "1.0"?> +<!DOCTYPE features_set SYSTEM "feat_set.dtd"> +<features_set version = "1.0"/> +''') + # Note: Order of elements in settings file should be same as order in feat_all + # (because this is the way TypeTuner does it and some fonts may expect this) + for name, ttfeat in sorted(feat_all.items(), key=lambda x: x[1].sortkey): + if name in ttsettings: + # Output the non-default value for this one: + ET.SubElement(root, 'feature',{'name': name, 'value': ttsettings[name]}) + else: + ET.SubElement(root, 'feature', {'name': name, 'value': ttfeat.default}) + xml = ET.tostring(root,pretty_print = True, encoding='UTF-8', xml_declaration=True) + with open(sfname, '+wb')as f: + f.write(xml) + + # Now invoke TypeTuner to create the tuned font + try: + cmd = ['typetuner', '-o', ttfname, '-n', font_tag, sfname, sourcettf] + res = check_output(cmd) + if len(res): + print('\n', res) + except CalledProcessError as err: + logger.log("couldn't tune font: {}".format(err.output), 'S') + + return font_tag + +def doit(args) : + + global logger, sourcettf, outputdir, fontdir + + logger = args.logger + sourcettf = args.ttfont + + # Create output directory, including fonts subdirectory, if not present + outputdir = args.outputdir + os.makedirs(outputdir, exist_ok = True) + fontdir = os.path.join(outputdir, 'fonts') + os.makedirs(fontdir, exist_ok = True) + + # Read and save feature mapping + for r in args.map: + # remove empty cells from the end + while len(r) and len(r[-1]) == 0: + r.pop() + if len(r) == 0 or r[0].startswith('#'): + continue + elif r[0].startswith('lang='): + if len(r[0]) < 7 or len(r) != 3: + logger.log("Invalid lang mapping: '" + ','.join(r) + "' ignored", "W") + else: + r[0] = r[0][5:] + lang_maps[r[0]] = lang_map(r) + else: + if len(r) < 4: + logger.log("Invalid feature mapping: '" + ','.join(r) + "' ignored", "W") + else: + feat_maps[r[0]] = feat_map(r) + + # Open and verify input file is a tunable font; extract and parse feat_all from font. + font = ttLib.TTFont(sourcettf) + raw_data = font.getTableData('Silt') + feat_xml = gzip.decompress(raw_data) # .decode('utf-8') + root = ET.fromstring(feat_xml) + if root.tag != 'all_features': + logger.log("Invalid TypeTuner feature file: missing root element", "S") + for i, f in enumerate(root.findall('.//feature')): + # add to dictionary + ttfeat = feat(f,i) + feat_all[ttfeat.name] = ttfeat + + # Open and prepare the xslt file to transform the ftml: + xslt = ET.parse(args.xsl) + xslt_transform = ET.XSLT(xslt) + + + # Process all ftml files: + + for arg in args.ftml: + for infname in glob(arg): + # based on input filename, construct output name + # find filename and change extension to html: + outfname = os.path.join(outputdir, os.path.splitext(os.path.basename(infname))[0] + '.html') + logger.log('Processing: {} -> {}'.format(infname, outfname), 'P') + + # Each named style in the FTML ultimately maps to a TypeTuned font that will be added via @fontface. + # We need to remember the names of the styles and their associated fonts so we can hack the html. + sname2font = dict() # Indexed by ftml stylename; result is a font object + + # Parse the FTML + ftml_doc = ET.parse(infname) + + # Adjust <title> to show this is from TypeTuner + head = ftml_doc.find('head') + title = head.find('title') + title.text += " - TypeTuner" + # Replace all <fontsrc> elements with two identical from the input font: + # One will remain unchanged, the other will eventually be changed to a typetuned font. + ET.strip_elements(head, 'fontsrc') + fpathname = os.path.relpath(sourcettf, outputdir).replace('\\','/') # for css make sure all slashes are forward! + head.append(ET.fromstring('<fontsrc>url({})</fontsrc>'.format(fpathname))) # First font + head.append(ET.fromstring('<fontsrc>url({})</fontsrc>'.format(fpathname))) # Second font, same as the first + + # iterate over all the styles in this ftml file, building tuned fonts to match if not already done. + for style in head.iter('style'): + sname = style.get('name') # e.g. "some_style" + feats = style.get('feats') # e.g "'cv02' 1, 'cv60' 1" -- this we'll parse to get need tt features + lang = style.get('lang') # e.g., "sd" + font_tag = cache_font(feats, lang, args.norebuild) + # font_tag could be None due to errors, but messages should already have been logged + # If it is valid, remember how to find this font from the ftml stylename + if font_tag: + sname2font[sname] = font_tag2font[font_tag] + + # convert to html via supplied xslt + html_doc = xslt_transform(ftml_doc) + + # Two modifications to make in the html: + # 1) add all @fontface specs to the <style> element + # 2) Fix up all occurrences of <td> elements referencing font2 + + # Add @fontface to <style> + style = html_doc.find('//style') + style.text = style.text + '\n' + '\n'.join([x.fontface for x in sname2font.values()]) + + # Iterate over all <td> elements looking for font2 and a style or lang indicating feature settings + + classRE = re.compile(r'string\s+(?:(\w+)\s+)?font2$') + + for td in html_doc.findall('//td'): + tdclass = td.get('class') + tdlang = td.get('lang') + m = classRE.match(tdclass) + if m: + sname = m.group(1) + if sname: + # stylename will get us directly to the font + try: + td.set('class', 'string {}'.format(sname2font[sname].font_tag)) + if tdlang: # If there is also a lang attribute, we no longer need it. + del td.attrib['lang'] + except KeyError: + logger.log("Style name {} not available.".format(sname), "W") + elif tdlang: + # Otherwise we'll assume there is only the lang attribute + try: + td.set('class', 'string {}'.format(lang2font[tdlang].font_tag)) + del td.attrib['lang'] # lang attribute no longer needed. + except KeyError: + logger.log("Style for langtag {} not available.".format(tdlang), "W") + + + # Ok -- write the html out! + html = ET.tostring(html_doc, pretty_print=True, method='html', encoding='UTF-8') + with open(outfname, '+wb')as f: + f.write(html) + + +def cmd() : execute(None,doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfftml2odt.py b/src/silfont/scripts/psfftml2odt.py new file mode 100644 index 0000000..c9408e3 --- /dev/null +++ b/src/silfont/scripts/psfftml2odt.py @@ -0,0 +1,453 @@ +#!/usr/bin/env python3 +__doc__ = 'read FTML file and generate LO writer .odt file' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015, SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Rowe' + +from silfont.core import execute +from fontTools import ttLib +from xml.etree import ElementTree as ET ### used to parse input FTML (may not be needed if FTML parser used) +import re +import os +import io +from odf.opendocument import OpenDocumentText, OpaqueObject +from odf.config import ConfigItem, ConfigItemSet +from odf.office import FontFaceDecls +from odf.style import FontFace, ParagraphProperties, Style, TableCellProperties, TableColumnProperties, TableProperties, TextProperties +from odf.svg import FontFaceSrc, FontFaceUri, FontFaceFormat +from odf.table import Table, TableCell, TableColumn, TableRow +from odf.text import H, P, SequenceDecl, SequenceDecls, Span + +# specify two parameters: input file (FTML/XML format), output file (ODT format) +# preceded by optional log file plus zero or more font strings +argspec = [ + ('input',{'help': 'Input file in FTML format'}, {'type': 'infile'}), + ('output',{'help': 'Output file (LO writer .odt)', 'nargs': '?'}, {'type': 'filename', 'def': '_out.odt'}), + ('-l','--log',{'help': 'Log file', 'required': False},{'type': 'outfile', 'def': '_ftml2odt_log.txt'}), + ('-r','--report',{'help': 'Set reporting level for log', 'type':str, 'choices':['X','S','E','P','W','I','V']},{}), + ('-f','--font',{'help': 'font specification','action': 'append', 'required': False}, {}), + ] + +# RegExs for extracting font name from fontsrc element +findfontnamelocal = re.compile(r"""local\( # begin with local( + (["']?) # optional open quote + (?P<fontstring>[^)]+) # font name + \1 # optional matching close quote + \)""", re.VERBOSE) # and end with ) +findfontnameurl = re.compile(r"""url\( # begin with local( + (["']?) # optional open quote + (?P<fontstring>[^)]+) # font name + \1 # optional matching close quote + \)""", re.VERBOSE) # and end with ) +fontspec = re.compile(r"""^ # beginning of string + (?P<rest>[A-Za-z ]+?) # Font Family Name + \s*(?P<bold>Bold)? # Bold + \s*(?P<italic>Italic)? # Italic + \s*(?P<regular>Regular)? # Regular + $""", re.VERBOSE) # end of string +# RegEx for extracting feature(s) from feats attribute of style element +onefeat = re.compile(r"""^\s* + '(?P<featname>[^']+)'\s* # feature tag + (?P<featval>[^', ]+)\s* # feature value + ,?\s* # optional comma + (?P<remainder>.*) # rest of line (with zero or more tag-value pairs) + $""", re.VERBOSE) +# RegEx for extracting language (and country) from lang attribute of style element +langcode = re.compile(r"""^ + (?P<langname>[A-Za-z]+) # language name + (- # (optional) hyphen and + (?P<countryname>[A-Za-z]+) # country name + (-[A-Za-z0-9][-A-Za-z0-9]*)? # (optional) hyphen and other codes + )?$""", re.VERBOSE) +# RegEx to extract hex value from \uxxxxxx and function to generate Unicode character +# use to change string to newstring: +# newstring = re.sub(backu, hextounichr, string) +# or newstring = re.sub(backu, lambda m: unichr(int(m.group(1),16)), string) +backu = re.compile(r"\\u([0-9a-fA-F]{4,6})") +def hextounichr(match): + return chr(int(match.group(1),16)) + +def BoldItalic(bold, italic): + rs = "" + if bold: + rs += " Bold" + if italic: + rs += " Italic" + return rs + +def parsefeats(inputline): + featdic = {} + while inputline != "": + results = re.match(onefeat, inputline) + if results: + featdic[results.group('featname')] = results.group('featval') + inputline = results.group('remainder') + else: + break ### warning about unrecognized feature string: inputline + return ":" + "&".join( [f + '=' + featdic[f] for f in sorted(featdic)]) + +def getfonts(fontsourcestrings, logfile, fromcommandline=True): + fontlist = [] + checkfontfamily = [] + checkembeddedfont = [] + for fs in fontsourcestrings: + if not fromcommandline: # from FTML <fontsrc> either local() or url() + installed = True # Assume locally installed font + results = re.match(findfontnamelocal, fs) + fontstring = results.group('fontstring') if results else None + if fontstring == None: + installed = False + results = re.match(findfontnameurl, fs) + fontstring = results.group('fontstring') if results else None + if fontstring == None: + logfile.log("Invalid font specification: " + fs, "S") + else: # from command line + fontstring = fs + if "." in fs: # must be a filename + installed = False + else: # must be an installed font + installed = True + if installed: + # get name, bold and italic info from string + results = re.match(fontspec, fontstring.strip()) + if results: + fontname = results.group('rest') + bold = results.group('bold') != None + italic = results.group('italic') != None + fontlist.append( (fontname, bold, italic, None) ) + if (fontname, bold, italic) in checkfontfamily: + logfile.log("Duplicate font specification: " + fs, "W") ### or more severe? + else: + checkfontfamily.append( (fontname, bold, italic) ) + else: + logfile.log("Invalid font specification: " + fontstring.strip(), "E") + else: + try: + # peek inside the font for the name, weight, style + f = ttLib.TTFont(fontstring) + # take name from name table, NameID 1, platform ID 3, Encoding ID 1 (possible fallback platformID 1, EncodingID =0) + n = f['name'] # name table from font + fontname = n.getName(1,3,1).toUnicode() # nameID 1 = Font Family name + # take bold and italic info from OS/2 table, fsSelection bits 0 and 5 + o = f['OS/2'] # OS/2 table + italic = (o.fsSelection & 1) > 0 + bold = (o.fsSelection & 32) > 0 + fontlist.append( (fontname, bold, italic, fontstring) ) + if (fontname, bold, italic) in checkfontfamily: + logfile.log("Duplicate font specification: " + fs + BoldItalic(bold, italic), "W") ### or more severe? + else: + checkfontfamily.append( (fontname, bold, italic) ) + if (os.path.basename(fontstring)) in checkembeddedfont: + logfile.log("Duplicate embedded font: " + fontstring, "W") ### or more severe? + else: + checkembeddedfont.append(os.path.basename(fontstring)) + except IOError: + logfile.log("Unable to find font file to embed: " + fontstring, "E") + except fontTools.ttLib.TTLibError: + logfile.log("File is not a valid font: " + fontstring, "E") + except: + logfile.log("Error occurred while checking font: " + fontstring, "E") # some other error + return fontlist + +def init(LOdoc, numfonts=1): + totalwid = 6800 #6.8inches + + #compute column widths + f = min(numfonts,4) + ashare = 4*(6-f) + dshare = 2*(6-f) + bshare = 100 - 2*ashare - dshare + awid = totalwid * ashare // 100 + dwid = totalwid * dshare // 100 + bwid = totalwid * bshare // (numfonts * 100) + + # create styles for table, for columns (one style for each column width) + # and for one cell (used for everywhere except where background changed) + tstyle = Style(name="Table1", family="table") + tstyle.addElement(TableProperties(attributes={'width':str(totalwid/1000.)+"in", 'align':"left"})) + LOdoc.automaticstyles.addElement(tstyle) + tastyle = Style(name="Table1.A", family="table-column") + tastyle.addElement(TableColumnProperties(attributes={'columnwidth':str(awid/1000.)+"in"})) + LOdoc.automaticstyles.addElement(tastyle) + tbstyle = Style(name="Table1.B", family="table-column") + tbstyle.addElement(TableColumnProperties(attributes={'columnwidth':str(bwid/1000.)+"in"})) + LOdoc.automaticstyles.addElement(tbstyle) + tdstyle = Style(name="Table1.D", family="table-column") + tdstyle.addElement(TableColumnProperties(attributes={'columnwidth':str(dwid/1000.)+"in"})) + LOdoc.automaticstyles.addElement(tdstyle) + ta1style = Style(name="Table1.A1", family="table-cell") + ta1style.addElement(TableCellProperties(attributes={'padding':"0.035in", 'border':"0.05pt solid #000000"})) + LOdoc.automaticstyles.addElement(ta1style) + # text style used with non-<em> text + t1style = Style(name="T1", family="text") + t1style.addElement(TextProperties(attributes={'color':"#999999" })) + LOdoc.automaticstyles.addElement(t1style) + # create styles for Title, Subtitle + tstyle = Style(name="Title", family="paragraph") + tstyle.addElement(TextProperties(attributes={'fontfamily':"Arial",'fontsize':"24pt",'fontweight':"bold" })) + LOdoc.styles.addElement(tstyle) + ststyle = Style(name="Subtitle", family="paragraph") + ststyle.addElement(TextProperties(attributes={'fontfamily':"Arial",'fontsize':"18pt",'fontweight':"bold" })) + LOdoc.styles.addElement(ststyle) + +def doit(args) : + logfile = args.logger + if args.report: logfile.loglevel = args.report + + try: + root = ET.parse(args.input).getroot() + except: + logfile.log("Error parsing FTML input", "S") + + if args.font: # font(s) specified on command line + fontlist = getfonts( args.font, logfile ) + else: # get font spec from FTML fontsrc element + fontlist = getfonts( [root.find("./head/fontsrc").text], logfile, False ) + #fontlist = getfonts( [fs.text for fs in root.findall("./head/fontsrc")], False ) ### would allow multiple fontsrc elements + numfonts = len(fontlist) + if numfonts == 0: + logfile.log("No font(s) specified", "S") + if numfonts > 1: + formattedfontnum = ["{0:02d}".format(n) for n in range(numfonts)] + else: + formattedfontnum = [""] + logfile.log("Font(s) specified:", "V") + for n, (fontname, bold, italic, embeddedfont) in enumerate(fontlist): + logfile.log(" " + formattedfontnum[n] + " " + fontname + BoldItalic(bold, italic) + " " + str(embeddedfont), "V") + + # get optional fontscale; compute pointsize as int(12*fontscale/100). If result xx is not 12, then add "fo:font-size=xxpt" in Px styles + pointsize = 12 + fontscaleel = root.find("./head/fontscale") + if fontscaleel != None: + fontscale = fontscaleel.text + try: + pointsize = int(int(fontscale)*12/100) + except ValueError: + # any problem leaves pointsize 12 + logfile.log("Problem with fontscale value; defaulting to 12 point", "W") + + # Get FTML styles and generate LO writer styles + # P2 is paragraph style for string element when no features specified + # each Px (for P3...) corresponds to an FTML style, which specifies lang or feats or both + # if numfonts > 1, two-digit font number is appended to make an LO writer style for each FTML style + font combo + # When LO writer style is used with attribute rtl="True", "R" appended to style name + LOstyles = {} + ftmlstyles = {} + Pstylenum = 2 + LOstyles["P2"] = ("", None, None) + ftmlstyles[0] = "P2" + for s in root.findall("./head/styles/style"): + Pstylenum += 1 + Pnum = "P" + str(Pstylenum) + featstring = "" + if s.get('feats'): + featstring = parsefeats(s.get('feats')) + langname = None + countryname = None + lang = s.get('lang') + if lang != None: + x = re.match(langcode, lang) + langname = x.group('langname') + countryname = x.group('countryname') + # FTML <test> element @stylename attribute references this <style> element @name attribute + ftmlstyles[s.get('name')] = Pnum + LOstyles[Pnum] = (featstring, langname, countryname) + + # create LOwriter file and construct styles for tables, column widths, etc. + LOdoc = OpenDocumentText() + init(LOdoc, numfonts) + # Initialize sequence counters + sds = SequenceDecls() + sd = sds.addElement(SequenceDecl(displayoutlinelevel = '0', name = 'Illustration')) + sd = sds.addElement(SequenceDecl(displayoutlinelevel = '0', name = 'Table')) + sd = sds.addElement(SequenceDecl(displayoutlinelevel = '0', name = 'Text')) + sd = sds.addElement(SequenceDecl(displayoutlinelevel = '0', name = 'Drawing')) + LOdoc.text.addElement(sds) + + # Create Px style for each (featstring, langname, countryname) tuple in LOstyles + # and for each font (if >1 font, append to Px style name a two-digit number corresponding to the font in fontlist) + # and (if at least one rtl attribute) suffix of nothing or "R" + # At the same time, collect info for creating FontFace elements (and any embedded fonts) + suffixlist = ["", "R"] if root.find(".//test/[@rtl='True']") != None else [""] + fontfaces = {} + for p in sorted(LOstyles, key = lambda x : int(x[1:])): # key = lambda x : int(x[1:]) corrects sort order + featstring, langname, countryname = LOstyles[p] + for n, (fontname, bold, italic, embeddedfont) in enumerate(fontlist): # embeddedfont = None if no embedding needed + fontnum = formattedfontnum[n] + # Collect fontface info: need one for each font family + feature combination + # Put embedded font in list only under fontname with empty featstring + if (fontname, featstring) not in fontfaces: + fontfaces[ (fontname, featstring) ] = [] + if embeddedfont: + if (fontname, "") not in fontfaces: + fontfaces[ (fontname, "") ] = [] + if embeddedfont not in fontfaces[ (fontname, "") ]: + fontfaces[ (fontname, "") ].append(embeddedfont) + # Generate paragraph styles + for s in suffixlist: + pstyle = Style(name=p+fontnum+s, family="paragraph") + if s == "R": + pstyle.addElement(ParagraphProperties(textalign="end", justifysingleword="false", writingmode="rl-tb")) + pstyledic = {} + pstyledic['fontnamecomplex'] = \ + pstyledic['fontnameasian'] =\ + pstyledic['fontname'] = fontname + featstring + pstyledic['fontsizecomplex'] = \ + pstyledic['fontsizeasian'] = \ + pstyledic['fontsize'] = str(pointsize) + "pt" + if bold: + pstyledic['fontweightcomplex'] = \ + pstyledic['fontweightasian'] = \ + pstyledic['fontweight'] = 'bold' + if italic: + pstyledic['fontstylecomplex'] = \ + pstyledic['fontstyleasian'] = \ + pstyledic['fontstyle'] = 'italic' + if langname != None: + pstyledic['languagecomplex'] = \ + pstyledic['languageasian'] = \ + pstyledic['language'] = langname + if countryname != None: + pstyledic['countrycomplex'] = \ + pstyledic['countryasian'] = \ + pstyledic['country'] = countryname + pstyle.addElement(TextProperties(attributes=pstyledic)) +# LOdoc.styles.addElement(pstyle) ### tried this, but when saving the generated odt, LO changed them to automatic styles + LOdoc.automaticstyles.addElement(pstyle) + + fontstoembed = [] + for fontname, featstring in sorted(fontfaces): ### Or find a way to keep order of <style> elements from original FTML? + ff = FontFace(name=fontname + featstring, fontfamily=fontname + featstring, fontpitch="variable") + LOdoc.fontfacedecls.addElement(ff) + if fontfaces[ (fontname, featstring) ]: # embedding needed for this combination + for fontfile in fontfaces[ (fontname, featstring) ]: + fontstoembed.append(fontfile) # make list for embedding + ffsrc = FontFaceSrc() + ffuri = FontFaceUri( **{'href': "Fonts/" + os.path.basename(fontfile), 'type': "simple"} ) + ffformat = FontFaceFormat( **{'string': 'truetype'} ) + ff.addElement(ffsrc) + ffsrc.addElement(ffuri) + ffuri.addElement(ffformat) + + basename = "Table1.B" + colorcount = 0 + colordic = {} # record color #rrggbb as key and "Table1.Bx" as stylename (where x is current color count) + tablenum = 0 + + # get title and comment and use as title and subtitle + titleel = root.find("./head/title") + if titleel != None: + LOdoc.text.addElement(H(outlinelevel=1, stylename="Title", text=titleel.text)) + commentel = root.find("./head/comment") + if commentel != None: + LOdoc.text.addElement(P(stylename="Subtitle", text=commentel.text)) + + # Each testgroup element begins a new table + for tg in root.findall("./testgroup"): + # insert label attribute of testgroup element as subtitle + tglabel = tg.get('label') + if tglabel != None: + LOdoc.text.addElement(H(outlinelevel=1, stylename="Subtitle", text=tglabel)) + + # insert text from comment subelement of testgroup element + tgcommentel = tg.find("./comment") + if tgcommentel != None: + #print("commentel found") + LOdoc.text.addElement(P(text=tgcommentel.text)) + + tgbg = tg.get('background') # background attribute of testgroup element + tablenum += 1 + table = Table(name="Table" + str(tablenum), stylename="Table1") + table.addElement(TableColumn(stylename="Table1.A")) + for n in range(numfonts): + table.addElement(TableColumn(stylename="Table1.B")) + table.addElement(TableColumn(stylename="Table1.A")) + table.addElement(TableColumn(stylename="Table1.D")) + for t in tg.findall("./test"): # Each test element begins a new row + # stuff to start the row + labeltext = t.get('label') + stylename = t.get('stylename') + stringel = t.find('./string') + commentel = t.find('./comment') + rtlsuffix = "R" if t.get('rtl') == 'True' else "" + comment = commentel.text if commentel != None else None + colBstyle = "Table1.A1" + tbg = t.get('background') # get background attribute of test group (if one exists) + if tbg == None: tbg = tgbg + if tbg != None: # if background attribute for test element (or background attribute for testgroup element) + if tbg not in colordic: # if color not found in color dic, create new style + colorcount += 1 + newname = basename + str(colorcount) + colordic[tbg] = newname + tb1style = Style(name=newname, family="table-cell") + tb1style.addElement(TableCellProperties(attributes={'padding':"0.0382in", 'border':"0.05pt solid #000000", 'backgroundcolor':tbg})) + LOdoc.automaticstyles.addElement(tb1style) + colBstyle = colordic[tbg] + + row = TableRow() + table.addElement(row) + # fill cells + # column A (label) + cell = TableCell(stylename="Table1.A1", valuetype="string") + if labeltext: + cell.addElement(P(stylename="Table_20_Contents", text = labeltext)) + row.addElement(cell) + + # column B (string) + for n in range(numfonts): + Pnum = ftmlstyles[stylename] if stylename != None else "P2" + Pnum = Pnum + formattedfontnum[n] + rtlsuffix + ### not clear if any of the following can be moved outside loop and reused + cell = TableCell(stylename=colBstyle, valuetype="string") + par = P(stylename=Pnum) + if len(stringel) == 0: # no <em> subelements + par.addText(re.sub(backu, hextounichr, stringel.text)) + else: # handle <em> subelement(s) + if stringel.text != None: + par.addElement(Span(stylename="T1", text = re.sub(backu, hextounichr, stringel.text))) + for e in stringel.findall("em"): + if e.text != None: + par.addText(re.sub(backu, hextounichr, e.text)) + if e.tail != None: + par.addElement(Span(stylename="T1", text = re.sub(backu, hextounichr, e.tail))) + cell.addElement(par) + row.addElement(cell) + + # column C (comment) + cell = TableCell(stylename="Table1.A1", valuetype="string") + if comment: + cell.addElement(P(stylename="Table_20_Contents", text = comment)) + row.addElement(cell) + + # column D (stylename) + cell = TableCell(stylename="Table1.A1", valuetype="string") + if comment: + cell.addElement(P(stylename="Table_20_Contents", text = stylename)) + row.addElement(cell) + LOdoc.text.addElement(table) + + LOdoc.text.addElement(P(stylename="Subtitle", text="")) # Empty paragraph to end ### necessary? + + try: + if fontstoembed: logfile.log("Embedding fonts in document", "V") + for f in fontstoembed: + LOdoc._extra.append( + OpaqueObject(filename = "Fonts/" + os.path.basename(f), + mediatype = "application/x-font-ttf", ### should be "application/font-woff" or "/font-woff2" for WOFF fonts, "/font-opentype" for ttf + content = io.open(f, "rb").read() )) + ci = ConfigItem(**{'name':'EmbedFonts', 'type': 'boolean'}) ### (name = 'EmbedFonts', type = 'boolean') + ci.addText('true') + cis=ConfigItemSet(**{'name':'ooo:configuration-settings'}) ### (name = 'ooo:configuration-settings') + cis.addElement(ci) + LOdoc.settings.addElement(cis) + except: + logfile.log("Error embedding fonts in document", "E") + logfile.log("Writing output file: " + args.output, "P") + LOdoc.save(args.output) + return + +def cmd() : execute("",doit, argspec) + +if __name__ == "__main__": cmd() + diff --git a/src/silfont/scripts/psfgetglyphnames.py b/src/silfont/scripts/psfgetglyphnames.py new file mode 100644 index 0000000..d81be69 --- /dev/null +++ b/src/silfont/scripts/psfgetglyphnames.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +__doc__ = '''Create a list of glyphs to import from a list of characters.''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019-2020 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Bobby de Vos' + +from silfont.core import execute + +suffix = "_psfgetglyphnames" +argspec = [ + ('ifont',{'help': 'Font file to copy from'}, {'type': 'infont'}), + ('glyphs',{'help': 'List of glyphs for psfcopyglyphs'}, {'type': 'outfile'}), + ('-i', '--input', {'help': 'List of characters to import'}, {'type': 'infile', 'def': None}), + ('-a','--aglfn',{'help': 'AGLFN list'}, {'type': 'incsv', 'def': None}), + ('-u','--uni',{'help': 'Generate uni or u glyph names if not in AGLFN', 'action': 'store_true', 'default': False}, {}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': suffix+'.log'}) + ] + +def doit(args) : + + font = args.ifont + + aglfn = dict() + if args.aglfn: + # Load Adobe Glyph List For New Fonts (AGLFN) + incsv = args.aglfn + incsv.numfields = 3 + + for line in incsv: + usv = line[0] + aglfn_name = line[1] + + codepoint = int(usv, 16) + aglfn[codepoint] = aglfn_name + + # Gather data from the UFO + cmap = dict() + for glyph in font: + for codepoint in glyph.unicodes: + cmap[codepoint] = glyph.name + + # Determine list of glyphs that need to be copied + header = ('glyph_name', 'rename', 'usv') + glyphs = args.glyphs + row = ','.join(header) + glyphs.write(row + '\n') + + for line in args.input: + + # Ignore comments + line = line.partition('#')[0] + line = line.strip() + + # Ignore blank lines + if line == '': + continue + + # Specify the glyph to copy + codepoint = int(line, 16) + usv = f'{codepoint:04X}' + + # Specify how to construct default AGLFN name + # if codepoint is not listed in the AGLFN file + glyph_prefix = 'uni' + if codepoint > 0xFFFF: + glyph_prefix = 'u' + + if codepoint in cmap: + # By default codepoints not listed in the AGLFN file + # will be imported with the glyph name of the source UFO + default_aglfn = '' + if args.uni: + # Provide AGLFN compatible names if requested + default_aglfn = f'{glyph_prefix}{usv}' + + # Create control file for use with psfcopyglyphs + aglfn_name = aglfn.get(codepoint, default_aglfn) + glyph_name = cmap[codepoint] + if '_' in glyph_name and aglfn_name == '': + aglfn_name = glyph_name.replace('_', '') + row = ','.join((glyph_name, aglfn_name, usv)) + glyphs.write(row + '\n') + + +def cmd() : execute("FP",doit, argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfglyphs2ufo.py b/src/silfont/scripts/psfglyphs2ufo.py new file mode 100644 index 0000000..7c8568d --- /dev/null +++ b/src/silfont/scripts/psfglyphs2ufo.py @@ -0,0 +1,284 @@ +#!/usr/bin/env python3 +__doc__ = '''Export fonts in a GlyphsApp file to UFOs''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Victor Gaultney' + +from silfont.core import execute +from silfont.ufo import obsoleteLibKeys + +import glyphsLib +import silfont.ufo +import silfont.etutil +from io import open +import os, shutil + +argspec = [ + ('glyphsfont', {'help': 'Input font file'}, {'type': 'filename'}), + ('masterdir', {'help': 'Output directory for masters'}, {}), + ('--nofixes', {'help': 'Bypass code fixing data', 'action': 'store_true', 'default': False}, {}), + ('--nofea', {'help': "Don't output features.fea", 'action': 'store_true', 'default': False}, {}), + ('--preservefea', {'help': "Retain the original features.fea in the UFO", 'action': 'store_true', 'default': False}, {}), + ('-l', '--log', {'help': 'Log file'}, {'type': 'outfile', 'def': '_glyphs2ufo.log'}), + ('-r', '--restore', {'help': 'List of extra keys to restore to fontinfo.plist or lib.plist'}, {})] + +def doit(args): + logger = args.logger + masterdir = args.masterdir + logger.log("Creating UFO objects from GlyphsApp file", "I") + with open(args.glyphsfont, 'r', encoding='utf-8') as gfile: + gfont = glyphsLib.parser.load(gfile) + ufos = glyphsLib.to_ufos(gfont, include_instances=False, family_name=None, propagate_anchors=False, generate_GDEF=False) + + keylists = { + + "librestorekeys": ["org.sil.pysilfontparams", "org.sil.altLineMetrics", "org.sil.lcg.toneLetters", + "org.sil.lcg.transforms", "public.glyphOrder", "public.postscriptNames", + "com.schriftgestaltung.disablesLastChange", "com.schriftgestaltung.disablesAutomaticAlignment", + "public.skipExportGlyphs"], + "libdeletekeys": ("com.schriftgestaltung.customParameter.GSFont.copyright", + "com.schriftgestaltung.customParameter.GSFont.designer", + "com.schriftgestaltung.customParameter.GSFont.manufacturer", + "com.schriftgestaltung.customParameter.GSFont.note", + "com.schriftgestaltung.customParameter.GSFont.Axes", + "com.schriftgestaltung.customParameter.GSFont.Axis Mappings", + "com.schriftgestaltung.customParameter.GSFontMaster.Master Name"), + "libdeleteempty": ("com.schriftgestaltung.DisplayStrings",), + "inforestorekeys": ["openTypeHeadCreated", "openTypeHeadFlags", "openTypeNamePreferredFamilyName", "openTypeNamePreferredSubfamilyName", + "openTypeNameUniqueID", "openTypeOS2WeightClass", "openTypeOS2WidthClass", "postscriptFontName", + "postscriptFullName", "styleMapFamilyName", "styleMapStyleName", "note", + "woffMetadataCredits", "woffMetadataDescription"], + "integerkeys": ("openTypeOS2WeightClass", "openTypeOS2WidthClass"), + "infodeletekeys": ("openTypeVheaVertTypoAscender", "openTypeVheaVertTypoDescender", "openTypeVheaVertTypoLineGap"), + # "infodeleteempty": ("openTypeOS2Selection",) + } + + if args.restore: # Extra keys to restore. Add to both lists, since should never be duplicated names + keylist = args.restore.split(",") + keylists["librestorekeys"] += keylist + keylists["inforestorekeys"].append(keylist) + + loglists = [] + obskeysfound={} + for ufo in ufos: + loglists.append(process_ufo(ufo, keylists, masterdir, args, obskeysfound)) + for loglist in loglists: + for logitem in loglist: logger.log(logitem[0], logitem[1]) + if obskeysfound: + logmess = "The following obsolete keys were found. They may have been in the original UFO or you may have an old version of glyphsLib installed\n" + for fontname in obskeysfound: + keys = obskeysfound[fontname] + logmess += " " + fontname + ": " + for key in keys: + logmess += key + ", " + logmess += "\n" + logger.log(logmess, "E") + +def process_ufo(ufo, keylists, masterdir, args, obskeysfound): + loglist=[] +# sn = ufo.info.styleName # ) +# sn = sn.replace("Italic Italic", "Italic") # ) Temp fixes due to glyphLib incorrectly +# sn = sn.replace("Italic Bold Italic", "Bold Italic") # ) forming styleName +# sn = sn.replace("Extra Italic Light Italic", "Extra Light Italic") # ) +# ufo.info.styleName = sn # ) + fontname = ufo.info.familyName.replace(" ", "") + "-" + ufo.info.styleName.replace(" ", "") + + # Fixes to the data + if not args.nofixes: + loglist.append(("Fixing data in " + fontname, "P")) + # lib.plist processing + loglist.append(("Checking lib.plist", "P")) + + # Restore values from original UFOs, assuming named as <fontname>.ufo in the masterdir + + ufodir = os.path.join(masterdir, fontname + ".ufo") + try: + origlibplist = silfont.ufo.Uplist(font=None, dirn=ufodir, filen="lib.plist") + except Exception as e: + loglist.append(("Unable to open lib.plist in " + ufodir + "; values will not be restored", "E")) + origlibplist = None + + if origlibplist is not None: + + for key in keylists["librestorekeys"]: + current = None if key not in ufo.lib else ufo.lib[key] + if key in origlibplist: + new = origlibplist.getval(key) + if current == new: + continue + else: + ufo.lib[key] = new + logchange(loglist, " restored from backup ufo. ", key, current, new) + elif current: + ufo.lib[key] = None + logchange(loglist, " removed since not in backup ufo. ", key, current, None) + + # Delete unneeded keys + + for key in keylists["libdeletekeys"]: + if key in ufo.lib: + current = ufo.lib[key] + del ufo.lib[key] + logchange(loglist, " deleted. ", key, current, None) + + for key in keylists["libdeleteempty"]: + if key in ufo.lib and (ufo.lib[key] == "" or ufo.lib[key] == []): + current = ufo.lib[key] + del ufo.lib[key] + logchange(loglist, " empty field deleted. ", key, current, None) + + # Check for obsolete keys + for key in obsoleteLibKeys: + if key in ufo.lib: + if fontname not in obskeysfound: obskeysfound[fontname] = [] + obskeysfound[fontname].append(key) + + # Special processing for Axis Mappings + #key = "com.schriftgestaltung.customParameter.GSFont.Axis Mappings" + #if key in ufo.lib: + # current =ufo.lib[key] + # new = dict(current) + # for x in current: + # val = current[x] + # k = list(val.keys())[0] + # if k[-2:] == ".0": new[x] = {k[0:-2]: val[k]} + # if current != new: + # ufo.lib[key] = new + # logchange(loglist, " key names set to integers. ", key, current, new) + + # Special processing for ufo2ft filters + key = "com.github.googlei18n.ufo2ft.filters" + if key in ufo.lib: + current = ufo.lib[key] + new = list(current) + for x in current: + if x["name"] == "eraseOpenCorners": + new.remove(x) + + if current != new: + if new == []: + del ufo.lib[key] + else: + ufo.lib[key] = new + logchange(loglist, " eraseOpenCorners filter removed ", key, current, new) + + # fontinfo.plist processing + + loglist.append(("Checking fontinfo.plist", "P")) + + try: + origfontinfo = silfont.ufo.Uplist(font=None, dirn=ufodir, filen="fontinfo.plist") + except Exception as e: + loglist.append(("Unable to open fontinfo.plist in " + ufodir + "; values will not be restored", "E")) + origfontinfo = None + + if origfontinfo is not None: + for key in keylists["inforestorekeys"]: + current = None if not hasattr(ufo.info, key) else getattr(ufo.info, key) + if key in origfontinfo: + new = origfontinfo.getval(key) + if key in keylists["integerkeys"]: new = int(new) + if current == new: + continue + else: + setattr(ufo.info, key, new) + logchange(loglist, " restored from backup ufo. ", key, current, new) + elif current: + setattr(ufo.info, key, None) + logchange(loglist, " removed since not in backup ufo. ", key, current, None) + + if getattr(ufo.info, "italicAngle") == 0: # Remove italicAngle if 0 + setattr(ufo.info, "italicAngle", None) + logchange(loglist, " removed", "italicAngle", 0, None) + + # Delete unneeded keys + + for key in keylists["infodeletekeys"]: + if hasattr(ufo.info, key): + current = getattr(ufo.info, key) + setattr(ufo.info, key, None) + logchange(loglist, " deleted. ", key, current, None) + +# for key in keylists["infodeleteempty"]: +# if hasattr(ufo.info, key) and getattr(ufo.info, key) == "": +# setattr(ufo.info, key, None) +# logchange(loglist, " empty field deleted. ", key, current, None) + if args.nofea or args.preservefea: ufo.features.text = "" # Suppress output of features.fea + + # Now check for glyph level changes needed + heightchanges = 0 + vertorichanges = 0 + for layer in ufo.layers: + for glyph in layer: + if glyph.height != 0: + loglist.append((f'Advance height of {str(glyph.height)} removed for {glyph.name}', "V")) + glyph.height = 0 + heightchanges += 1 + lib = glyph.lib + if "public.verticalOrigin" in lib: + del lib["public.verticalOrigin"] + vertorichanges += 1 + if heightchanges: loglist.append((f"{str(heightchanges)} advance heights removed from glyphs", "I")) + if vertorichanges: loglist.append((f"{str(vertorichanges)} public.verticalOrigins removed from lib in glyphs", "I")) + + # Write ufo out + ufopath = os.path.join(masterdir, fontname + ".ufo") + if args.preservefea: # Move features.fea out of the ufo so that it can be restored afterward + origfea = os.path.join(ufopath, "features.fea") + hiddenfea = os.path.join(masterdir, fontname + "features.tmp") + if os.path.exists(origfea): + loglist.append((f'Renaming {origfea} to {hiddenfea}', "I")) + os.rename(origfea, hiddenfea) + else: + loglist.append((f"{origfea} does not exists so can't be restored", "E")) + origfea = None + loglist.append(("Writing out " + ufopath, "P")) + if os.path.exists(ufopath): shutil.rmtree(ufopath) + ufo.save(ufopath) + if args.preservefea and origfea: + loglist.append((f'Renaming {hiddenfea} back to {origfea}', "I")) + os.rename(hiddenfea, origfea) + + # Now correct the newly-written fontinfo.plist with changes that can't be made via glyphsLib + if not args.nofixes: + fontinfo = silfont.ufo.Uplist(font=None, dirn=ufopath, filen="fontinfo.plist") + changes = False + for key in ("guidelines", "postscriptBlueValues", "postscriptFamilyBlues", "postscriptFamilyOtherBlues", + "postscriptOtherBlues"): + if key in fontinfo and fontinfo.getval(key) == []: + fontinfo.remove(key) + changes = True + logchange(loglist, " empty list deleted", key, None, []) + if changes: + # Create outparams. Just need any valid values, since font will need normalizing later + params = args.paramsobj + paramset = params.sets["main"] + outparams = {"attribOrders": {}} + for parn in params.classes["outparams"]: outparams[parn] = paramset[parn] + loglist.append(("Writing updated fontinfo.plist", "I")) + silfont.ufo.writeXMLobject(fontinfo, params=outparams, dirn=ufopath, filen="fontinfo.plist", exists=True, + fobject=True) + return loglist + +def logchange(loglist, logmess, key, old, new): + oldstr = str(old) if len(str(old)) < 22 else str(old)[0:20] + "..." + newstr = str(new) if len(str(new)) < 22 else str(new)[0:20] + "..." + logmess = key + logmess + if old is None: + logmess = logmess + " New value: " + newstr + else: + if new is None: + logmess = logmess + " Old value: " + oldstr + else: + logmess = logmess + " Old value: " + oldstr + ", new value: " + newstr + loglist.append((logmess, "I")) + # Extra verbose logging + if len(str(old)) > 21 : + loglist.append(("Full old value: " + str(old), "V")) + if len(str(new)) > 21 : + loglist.append(("Full new value: " + str(new), "V")) + loglist.append(("Types: Old - " + str(type(old)) + ", New - " + str(type(new)), "V")) + +def cmd(): execute(None, doit, argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfmakedeprecated.py b/src/silfont/scripts/psfmakedeprecated.py new file mode 100644 index 0000000..967b34c --- /dev/null +++ b/src/silfont/scripts/psfmakedeprecated.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +'''Creates deprecated versions of glyphs: takes the specified glyph and creates a +duplicate with an additional box surrounding it so that it becomes reversed, +and assigns a new unicode encoding to it. +Input is a csv with three fields: original,new,unicode''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Victor Gaultney' + +from silfont.core import execute + +argspec = [ + ('ifont', {'help': 'Input font filename'}, {'type': 'infont'}), + ('ofont',{'help': 'Output font file','nargs': '?' }, {'type': 'outfont'}), + ('-i','--input',{'help': 'Input csv file'}, {'type': 'incsv', 'def': 'todeprecate.csv'}), + ('-l','--log',{'help': 'Set log file name'}, {'type': 'outfile', 'def': '_deprecated.log'})] + +offset = 30 + +def doit(args) : + font = args.ifont + logger = args.logger + + # Process csv list into a dictionary structure + args.input.numfields = 3 + deps = {} + for line in args.input : + deps[line[0]] = {"newname": line[1], "newuni": line[2]} + + # Iterate through dictionary (unsorted) + for source, target in deps.items() : + # Check if source glyph is in font + if source in font.keys() : + # Give warning if target is already in font, but overwrite anyway + targetname = target["newname"] + targetuni = int(target["newuni"], 16) + if targetname in font.keys() : + logger.log("Warning: " + targetname + " already in font and will be replaced") + + # Make a copy of source into a new glyph object + sourceglyph = font[source] + newglyph = sourceglyph.copy() + + # Draw box around it + xmin, ymin, xmax, ymax = sourceglyph.bounds + pen = newglyph.getPen() + pen.moveTo((xmax + offset, ymin - offset)) + pen.lineTo((xmax + offset, ymax + offset)) + pen.lineTo((xmin - offset, ymax + offset)) + pen.lineTo((xmin - offset, ymin - offset)) + pen.closePath() + + # Set unicode + newglyph.unicodes = [] + newglyph.unicode = targetuni + + # Add the new glyph object to the font with name target + font.__setitem__(targetname,newglyph) + + # Decompose glyph in case there may be components + # It seems you can't decompose a glyph has hasn't yet been added to a font + font[targetname].decompose() + # Correct path direction + font[targetname].correctDirection() + + logger.log(source + " duplicated to " + targetname) + else : + logger.log("Warning: " + source + " not in font") + + return font + +def cmd() : execute("FP",doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfmakefea.py b/src/silfont/scripts/psfmakefea.py new file mode 100644 index 0000000..e335230 --- /dev/null +++ b/src/silfont/scripts/psfmakefea.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +__doc__ = 'Make features.fea file' +# TODO: add conditional compilation, compare to fea, compile to ttf +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Martin Hosken, Alan Ward' + +import silfont.ufo as ufo +from collections import OrderedDict +from silfont.feax_parser import feaplus_parser +from xml.etree import ElementTree as et +import re + +from silfont.core import execute + +def getbbox(g): + res = (65536, 65536, -65536, -65536) + if g['outline'] is None: + return (0, 0, 0, 0) + for c in g['outline'].contours: + for p in c['point']: + if 'type' in p.attrib: # any actual point counts + x = float(p.get('x', '0')) + y = float(p.get('y', '0')) + res = (min(x, res[0]), min(y, res[1]), max(x, res[2]), max(y, res[3])) + return res + +class Glyph(object) : + def __init__(self, name, advance=0, bbox=None) : + self.name = name + self.anchors = {} + self.is_mark = False + self.advance = int(float(advance)) + self.bbox = bbox or (0, 0, 0, 0) + + def add_anchor(self, info) : + self.anchors[info['name']] = (int(float(info['x'])), int(float(info['y']))) + + def decide_if_mark(self) : + for a in self.anchors.keys() : + if a.startswith("_") : + self.is_mark = True + break + +def decode_element(e): + '''Convert plist element into python structures''' + res = None + if e.tag == 'string': + return e.text + elif e.tag == 'integer': + return int(e.text) + elif e.tag== 'real': + return float(e.text) + elif e.tag == 'array': + res = [decode_element(x) for x in e] + elif e.tag == 'dict': + res = {} + for p in zip(e[::2], e[1::2]): + res[p[0].text] = decode_element(p[1]) + return res + +class Font(object) : + def __init__(self, defines = None): + self.glyphs = OrderedDict() + self.classes = OrderedDict() + self.all_aps = OrderedDict() + self.fontinfo = {} + self.kerns = {} + self.defines = {} if defines is None else defines + + def readaps(self, filename, omitaps='', params = None) : + omittedaps = set(omitaps.replace(',',' ').split()) # allow comma- and/or space-separated list + if filename.endswith('.ufo') : + f = ufo.Ufont(filename, params = params) + self.fontinfo = {} + for k, v in f.fontinfo._contents.items(): + self.fontinfo[k] = decode_element(v[1]) + skipglyphs = set(f.lib.getval('public.skipExportGlyphs', [])) + for g in f.deflayer : + if g in skipglyphs: + continue + ufo_g = f.deflayer[g] + advb = ufo_g['advance'] + adv = advb.width if advb is not None and advb.width is not None else 0 + bbox = getbbox(ufo_g) + glyph = Glyph(g, advance=adv, bbox=bbox) + self.glyphs[g] = glyph + if 'anchor' in ufo_g._contents : + for a in ufo_g._contents['anchor'] : + if a.element.attrib['name'] not in omittedaps: + glyph.add_anchor(a.element.attrib) + self.all_aps.setdefault(a.element.attrib['name'], []).append(glyph) + if hasattr(f, 'groups'): + for k, v in f.groups._contents.items(): + self.classes[k.lstrip('@')] = decode_element(v[1]) + if hasattr(f, 'kerning'): + for k, v in f.kerning._contents.items(): + key = k.lstrip('@') + if key in self.classes: + key = "@" + key + subelements = decode_element(v[1]) + kerndict = {} + for s, n in subelements.items(): + skey = s.lstrip('@') + if skey in self.classes: + skey = "@" + skey + kerndict[skey] = n + self.kerns[key] = kerndict + elif filename.endswith('.xml') : + currGlyph = None + currPoint = None + self.fontinfo = {} + for event, elem in et.iterparse(filename, events=('start', 'end')): + if event == 'start': + if elem.tag == 'glyph': + name = elem.get('PSName', '') + if name: + currGlyph = Glyph(name) + self.glyphs[name] = currGlyph + currPoint = None + elif elem.tag == 'point': + currPoint = {'name' : elem.get('type', '')} + elif elem.tag == 'location' and currPoint is not None: + currPoint['x'] = int(elem.get('x', 0)) + currPoint['y'] = int(elem.get('y', 0)) + elif elem.tag == 'font': + n = elem.get('name', '') + x = n.split('-') + if len(x) == 2: + self.fontinfo['familyName'] = x[0] + self.fontinfo['openTypeNamePreferredFamilyName'] = x[0] + self.fontinfo['styleMapFamilyName'] = x[0] + self.fontinfo['styleName'] = x[1] + self.fontinfo['openTypeNamePreferredSubfamilyName'] = x[1] + self.fontinfo['postscriptFullName'] = "{0} {1}".format(*x) + self.fontinfo['postscriptFontName'] = n + elif event == 'end': + if elem.tag == 'point': + if currGlyph and currPoint['name'] not in omittedaps: + currGlyph.add_anchor(currPoint) + self.all_aps.setdefault(currPoint['name'], []).append(currGlyph) + currPoint = None + elif elem.tag == 'glyph': + currGlyph = None + + def read_classes(self, fname, classproperties=False): + doc = et.parse(fname) + for c in doc.findall('.//class'): + class_name = c.get('name') + m = re.search('\[(\d+)\]$', class_name) + # support fixedclasses like make_gdl.pl via AP.pm + if m: + class_nm = class_name[0:m.start()] + ix = int(m.group(1)) + else: + class_nm = class_name + ix = None + cl = self.classes.setdefault(class_nm, []) + for e in c.get('exts', '').split() + [""]: + for g in c.text.split(): + if g+e in self.glyphs or (e == '' and g.startswith('@')): + if ix: + cl.insert(ix, g+e) + else: + cl.append(g+e) + if not classproperties: + return + for c in doc.findall('.//property'): + for e in c.get('exts', '').split() + [""]: + for g in c.text.split(): + if g+e in self.glyphs: + cname = c.get('name') + "_" + c.get('value') + self.classes.setdefault(cname, []).append(g+e) + + def make_classes(self, ligmode) : + for name, g in self.glyphs.items() : + # pull off suffix and make classes + # TODO: handle ligatures + base = name + if ligmode is None or 'comp' not in ligmode or "_" not in name: + pos = base.rfind('.') + while pos > 0 : + old_base = base + ext = base[pos+1:] + base = base[:pos] + ext_class_nm = "c_" + ext + if base in self.glyphs and old_base in self.glyphs: + glyph_lst = self.classes.setdefault(ext_class_nm, []) + if not old_base in glyph_lst: + glyph_lst.append(old_base) + self.classes.setdefault("cno_" + ext, []).append(base) + pos = base.rfind('.') + if ligmode is not None and "_" in name: + comps = name.split("_") + if "comp" in ligmode or "." not in comps[-1]: + base = comps.pop(-1 if "last" in ligmode else 0) + cname = base.replace(".", "_") + noname = "_".join(comps) + if base in self.glyphs and noname in self.glyphs: + glyph_lst = self.classes.setdefault("clig_"+cname, []) + if name not in glyph_lst: + glyph_lst.append(name) + self.classes.setdefault("cligno_"+cname, []).append(noname) + if g.is_mark : + self.classes.setdefault('GDEF_marks', []).append(name) + else : + self.classes.setdefault('GDEF_bases', []).append(name) + + def make_marks(self) : + for name, g in self.glyphs.items() : + g.decide_if_mark() + + def order_classes(self): + # return ordered list of classnames as desired for FEA + + # Start with alphabetical then correct: + # 1. Put classes like "cno_whatever" adjacent to "c_whatever" + # 2. Classes can be defined in terms of other classes but FEA requires that + # classes be defined before they can be referenced. + + def sortkey(x): + key1 = 'c_' + x[4:] if x.startswith('cno_') else x + return (key1, x) + + classes = sorted(self.classes.keys(), key=sortkey) + links = {} # key = classname; value = list of other classes that include this one + counts = {} # key = classname; value = count of un-output classes that this class includes + for name in classes: + y = [c[1:] for c in self.classes[name] if c.startswith('@')] #list of included classes + counts[name] = len(y) + for c in y: + links.setdefault(c, []).append(name) + + outclasses = [] + while len(classes) > 0: + foundone = False + for name in classes: + if counts[name] == 0: + foundone = True + # output this class + outclasses.append(name) + classes.remove(name) + # adjust counts of classes that include this one + if name in links: + for n in links[name]: + counts[n] -= 1 + # It may now be possible to output some we skipped earlier, + # so start over from the beginning of the list + break + if not foundone: + # all remaining classes include un-output classes and thus there is a loop somewhere + raise ValueError("Class reference loop(s) found: " + ", ".join(classes)) + return outclasses + + def addComment(self, parser, text): + cmt = parser.ast.Comment("# " + text, location=None) + cmt.pretext = "\n" + parser.add_statement(cmt) + + def append_classes(self, parser) : + # normal glyph classes + self.addComment(parser, "Main Classes") + for name in self.order_classes(): + gc = parser.ast.GlyphClass(None, location=None) + for g in self.classes[name] : + gc.append(g) + gcd = parser.ast.GlyphClassDefinition(name, gc, location=None) + parser.add_statement(gcd) + parser.define_glyphclass(name, gcd) + + def _addGlyphsToClass(self, parser, glyphs, gc, anchor, definer): + if len(glyphs) > 1 : + val = parser.ast.GlyphClass(glyphs, location=None) + else : + val = parser.ast.GlyphName(glyphs[0], location=None) + classdef = definer(gc, anchor, val, location=None) + gc.addDefinition(classdef) + parser.add_statement(classdef) + + def append_positions(self, parser): + # create base and mark classes, add to fea file dicts and parser symbol table + bclassdef_lst = [] + mclassdef_lst = [] + self.addComment(parser, "Positioning classes and statements") + for ap_nm, glyphs_w_ap in self.all_aps.items() : + self.addComment(parser, "AP: " + ap_nm) + # e.g. all glyphs with U AP + if not ap_nm.startswith("_"): + if any(not x.is_mark for x in glyphs_w_ap): + gcb = parser.set_baseclass(ap_nm) + parser.add_statement(gcb) + if any(x.is_mark for x in glyphs_w_ap): + gcm = parser.set_baseclass(ap_nm + "_MarkBase") + parser.add_statement(gcm) + else: + gc = parser.set_markclass(ap_nm) + + # create lists of glyphs that use the same point (name and coordinates) + # that can share a class definition + anchor_cache = OrderedDict() + markanchor_cache = OrderedDict() + for g in glyphs_w_ap : + p = g.anchors[ap_nm] + if g.is_mark and not ap_nm.startswith("_"): + markanchor_cache.setdefault(p, []).append(g.name) + else: + anchor_cache.setdefault(p, []).append(g.name) + + if ap_nm.startswith("_"): + for p, glyphs_w_pt in anchor_cache.items(): + anchor = parser.ast.Anchor(p[0], p[1], location=None) + self._addGlyphsToClass(parser, glyphs_w_pt, gc, anchor, parser.ast.MarkClassDefinition) + else: + for p, glyphs_w_pt in anchor_cache.items(): + anchor = parser.ast.Anchor(p[0], p[1], location=None) + self._addGlyphsToClass(parser, glyphs_w_pt, gcb, anchor, parser.ast.BaseClassDefinition) + for p, glyphs_w_pt in markanchor_cache.items(): + anchor = parser.ast.Anchor(p[0], p[1], location=None) + self._addGlyphsToClass(parser, glyphs_w_pt, gcm, anchor, parser.ast.BaseClassDefinition) + +#TODO: provide more argument info +argspec = [ + ('infile', {'nargs': '?', 'help': 'Input UFO or file'}, {'def': None, 'type': 'filename'}), + ('-i', '--input', {'required': 'True', 'help': 'Fea file to merge'}, {}), + ('-o', '--output', {'help': 'Output fea file'}, {}), + ('-c', '--classfile', {'help': 'Classes file'}, {}), + ('-L', '--ligmode', {'help': 'Parse ligatures: last - use last element as class name, first - use first element as class name, lastcomp, firstcomp - final variants are part of the component not the whole ligature'}, {}), + ('-D', '--define', {'action': 'append', 'help': 'Add option definition to pass to fea code --define=var=val'}, {}), + # ('--debug', {'help': 'Drop into pdb', 'action': 'store_true'}, {}), + ('--classprops', {'help': 'Include property elements from classes file', 'action': 'store_true'}, {}), + ('--omitaps', {'help': 'names of attachment points to omit (comma- or space-separated)', 'default': '', 'action': 'store'}, {}) +] + +def doit(args) : + defines = dict(x.split('=') for x in args.define) if args.define else {} + font = Font(defines) + # if args.debug: + # import pdb; pdb.set_trace() + if "checkfix" not in args.params: + args.paramsobj.sets["main"]["checkfix"] = "None" + if args.infile is not None: + font.readaps(args.infile, args.omitaps, args.paramsobj) + + font.make_marks() + font.make_classes(args.ligmode) + if args.classfile: + font.read_classes(args.classfile, classproperties = args.classprops) + + p = feaplus_parser(None, font.glyphs, font.fontinfo, font.kerns, font.defines) + doc_ufo = p.parse() # returns an empty ast.FeatureFile + + # Add goodies from the font + font.append_classes(p) + font.append_positions(p) + + # parse the input fea file + if args.input : + doc_fea = p.parse(args.input) + else: + doc_fea = doc_ufo + + # output as doc.asFea() + if args.output : + with open(args.output, "w") as of : + of.write(doc_fea.asFea()) + +def cmd(): execute(None, doit, argspec) +if __name__ == '__main__': cmd() diff --git a/src/silfont/scripts/psfmakescaledshifted.py b/src/silfont/scripts/psfmakescaledshifted.py new file mode 100644 index 0000000..b0bf5c1 --- /dev/null +++ b/src/silfont/scripts/psfmakescaledshifted.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +'''Creates duplicate versions of glyphs that are scaled and shifted. +Input is a csv with three fields: original,new,unicode''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Victor Gaultney' + +from silfont.core import execute +from silfont.util import parsecolors +from ast import literal_eval as make_tuple + +argspec = [ + ('ifont', {'help': 'Input font filename'}, {'type': 'infont'}), + ('ofont',{'help': 'Output font file','nargs': '?' }, {'type': 'outfont'}), + ('-i','--input',{'help': 'Input csv file', 'required': True}, {'type': 'incsv', 'def': 'scaledshifted.csv'}), + ('-c', '--colorcells', {'help': 'Color cells of generated glyphs', 'action': 'store_true'}, {}), + ('--color', {'help': 'Color to use when marking generated glyphs'},{}), + ('-t','--transform',{'help': 'Transform matrix or type', 'required': True}, {}), + ('-l','--log',{'help': 'Set log file name'}, {'type': 'outfile', 'def': '_scaledshifted.log'})] + +def doit(args) : + font = args.ifont + logger = args.logger + transform = args.transform + + if transform[1] == "(": + # Set transform from matrix - example: "(0.72, 0, 0, 0.6, 10, 806)" + # (xx, xy, yx, yy, x, y) + trans = make_tuple(args.transform) + else: + # Set transformation specs from UFO lib.plist org.sil.lcg.transforms + # Will need to be enhanced to support adjustMetrics, boldX, boldY parameters for smallcaps + try: + trns = font.lib["org.sil.lcg.transforms"][transform] + except KeyError: + logger.log("Error: transform type not found in lib.plist org.sil.lcg.transforms", "S") + else: + try: + adjM = trns["adjustMetrics"] + except KeyError: + adjM = 0 + try: + skew = trns["skew"] + except KeyError: + skew = 0 + try: + shiftX = trns["shiftX"] + except KeyError: + shiftX = 0 + try: + shiftY = trns["shiftY"] + except KeyError: + shiftY = 0 + trans = (trns["scaleX"], 0, skew, trns["scaleY"], shiftX+adjM, shiftY) + + + # Process csv list into a dictionary structure + args.input.numfields = 3 + deps = {} + for (source, newname, newuni) in args.input : + if source in deps: + deps[source].append({"newname": newname, "newuni": newuni}) + else: + deps[source] = [({"newname": newname, "newuni": newuni})] + + # Iterate through dictionary (unsorted) + for source in deps: + # Check if source glyph is in font + if source in font.keys() : + for target in deps[source]: + # Give warning if target is already in font, but overwrite anyway + targetname = target["newname"] + if targetname in font.keys() : + logger.log("Warning: " + targetname + " already in font and will be replaced") + + # Make a copy of source into a new glyph object + sourceglyph = font[source] + newglyph = sourceglyph.copy() + + newglyph.transformBy(trans) + # Set width because transformBy does not seems to properly adjust width + newglyph.width = (int(newglyph.width * trans[0])) + (adjM * 2) + + # Set unicode + newglyph.unicodes = [] + if target["newuni"]: + newglyph.unicode = int(target["newuni"], 16) + + # mark glyphs as being generated by setting cell mark color (defaults to blue if args.color not set) + if args.colorcells or args.color: + if args.color: + (color, name, logcolor, splitcolor) = parsecolors(args.color, single=True) + if color is None: logger.log(logcolor, "S") # If color not parsed, parsecolors() puts error in logcolor + color = color.split(",") # Need to convert string to tuple + color = (float(color[0]), float(color[1]), float(color[2]), float(color[3])) + else: + color = (0.18, 0.16, 0.78, 1) + newglyph.markColor = color + + # Add the new glyph object to the font with name target + font.__setitem__(targetname, newglyph) + + # Decompose glyph in case there may be components + # It seems you can't decompose a glyph has hasn't yet been added to a font + font[targetname].decompose() + # Correct path direction + font[targetname].correctDirection() + + logger.log(source + " duplicated to " + targetname) + else : + logger.log("Warning: " + source + " not in font") + + return font + +def cmd() : execute("FP",doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfmakewoffmetadata.py b/src/silfont/scripts/psfmakewoffmetadata.py new file mode 100644 index 0000000..c6feb45 --- /dev/null +++ b/src/silfont/scripts/psfmakewoffmetadata.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python3 +__doc__ = 'Make the WOFF metadata xml file based on input UFO (and optionally FONTLOG.txt)' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute +import silfont.ufo as UFO + +import re, os, datetime +from xml.etree import ElementTree as ET + +argspec = [ + ('font', {'help': 'Source font file'}, {'type': 'infont'}), + ('-n', '--primaryname', {'help': 'Primary Font Name', 'required': True}, {}), + ('-i', '--orgid', {'help': 'orgId', 'required': True}, {}), + ('-f', '--fontlog', {'help': 'FONTLOG.txt file', 'default': 'FONTLOG.txt'}, {'type': 'filename'}), + ('-o', '--output', {'help': 'Override output file'}, {'type': 'filename', 'def': None}), + ('--populateufowoff', {'help': 'Copy info from FONTLOG.txt to UFO', 'action': 'store_true', 'default': False},{}), + ('-l', '--log', {'help': 'Log file'}, {'type': 'outfile', 'def': '_makewoff.log'})] + +def doit(args): + font = args.font + pfn = args.primaryname + orgid = args.orgid + logger = args.logger + ofn = args.output + + # Find & process info required in the UFO + + fi = font.fontinfo + + ufofields = {} + missing = None + for field in ("versionMajor", "versionMinor", "openTypeNameManufacturer", "openTypeNameManufacturerURL", + "openTypeNameLicense", "copyright", "trademark"): + if field in fi: + ufofields[field] = fi[field][1].text + elif field != 'trademark': # trademark is no longer required + missing = field if missing is None else missing + ", " + field + if missing is not None: logger.log("Field(s) missing from fontinfo.plist: " + missing, "S") + + version = ufofields["versionMajor"] + "." + ufofields["versionMinor"].zfill(3) + + # Find & process WOFF fields if present in the UFO + + missing = None + ufofields["woffMetadataDescriptionurl"] = None + ufowoff = {"woffMetadataCredits": "credits", "woffMetadataDescription": "text"} # Field, dict name + for field in ufowoff: + fival = fi.getval(field) if field in fi else None + if fival is None: + missing = field if missing is None else missing + ", " + field + ufofields[field] = None + else: + ufofields[field] = fival[ufowoff[field]] + if field == "woffMetadataDescription" and "url" in fival: + ufofields["woffMetadataDescriptionurl"] = fival["url"] + + # Process --populateufowoff setting, if present + if args.populateufowoff: + if missing != "woffMetadataCredits, woffMetadataDescription": + logger.log("Data exists in the UFO for woffMetadata - remove manually to reuse --populateufowoff", "S") + + if args.populateufowoff or missing is not None: + if missing: logger.log("WOFF field(s) missing from fontinfo.plist will be generated from FONTLOG.txt: " + missing, "W") + # Open the fontlog file + try: + fontlog = open(args.fontlog) + except Exception as e: + logger.log(f"Unable to open {args.fontlog}: {str(e)}", "S") + # Parse the fontlog file + (section, match) = readuntil(fontlog, ("Basic Font Information",)) # Skip until start of "Basic Font Information" section + if match is None: logger.log("No 'Basic Font Information' section in fontlog", "S") + (fldescription, match) = readuntil(fontlog, ("Information for C", "Acknowledgements")) # Description ends when first of these sections is found + fldescription = [{"text": fldescription}] + if match == "Information for C": (section, match) = readuntil(fontlog, ("Acknowledgements",)) # If Info... section present then skip on to Acknowledgements + if match is None: logger.log("No 'Acknowledgements' section in fontlog", "S") + (acksection, match) = readuntil(fontlog, ("No match needed!!",)) + + flcredits = [] + credit = {} + acktype = "" + flog2woff = {"N": "name", "E": "Not used", "W": "url", "D": "role"} + for line in acksection.splitlines(): + if line == "": + if acktype != "": # Must be at the end of a credit section + if "name" in credit: + flcredits.append(credit) + else: + logger.log("Credit section found with no N: entry", "E") + credit = {} + acktype = "" + else: + match = re.match("^([NEWD]): (.*)", line) + if match is None: + if acktype == "N": credit["name"] = credit["name"] + line # Name entries can be multiple lines + else: + acktype = match.group(1) + if acktype in credit: + logger.log("Multiple " + acktype + " entries found in a credit section", "E") + else: + credit[flog2woff[acktype]] = match.group(2) + if flcredits == []: logger.log("No credits found in fontlog", "S") + if args.populateufowoff: + ufofields["woffMetadataDescription"] = fldescription # Force fontlog values to be used writing metadata.xml later + ufofields["woffMetadataCredits"] = flcredits + # Create xml strings and update fontinfo + xmlstring = "<dict>" + \ + "<key>text</key><array><dict>" + \ + "<key>text</key><string>" + textprotect(fldescription[0]["text"]) + "</string>" + \ + "</dict></array>" + \ + "<key>url</key><string>https://software.sil.org/</string>"\ + "</dict>" + fi.setelem("woffMetadataDescription", ET.fromstring(xmlstring)) + + xmlstring = "<dict><key>credits</key><array>" + for credit in flcredits: + xmlstring += '<dict><key>name</key><string>' + textprotect(credit["name"]) + '</string>' + if "url" in credit: xmlstring += '<key>url</key><string>' + textprotect(credit["url"]) + '</string>' + if "role" in credit: xmlstring += '<key>role</key><string>' + textprotect(credit["role"]) + '</string>' + xmlstring += '</dict>' + xmlstring += '</array></dict>' + fi.setelem("woffMetadataCredits", ET.fromstring(xmlstring)) + + fi.setval("openTypeHeadCreated", "string", datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S")) + logger.log("Writing updated fontinfo.plist with values from FONTLOG.txt", "P") + exists = True if os.path.isfile(os.path.join(font.ufodir, "fontinfo.plist")) else False + UFO.writeXMLobject(fi, font.outparams, font.ufodir, "fontinfo.plist", exists, fobject=True) + + description = ufofields["woffMetadataDescription"] + if description == None: description = fldescription + credits = ufofields["woffMetadataCredits"] + if credits == None : credits = flcredits + + # Construct output file name + (folder, ufoname) = os.path.split(font.ufodir) + filename = os.path.join(folder, pfn + "-WOFF-metadata.xml") if ofn is None else ofn + try: + file = open(filename, "w") + except Exception as e: + logger.log("Unable to open " + filename + " for writing:\n" + str(e), "S") + logger.log("Writing to : " + filename, "P") + + file.write('<?xml version="1.0" encoding="UTF-8"?>\n') + file.write('<metadata version="1.0">\n') + file.write(' <uniqueid id="' + orgid + '.' + pfn + '.' + version + '" />\n') + file.write(' <vendor name="' + attrprotect(ufofields["openTypeNameManufacturer"]) + '" url="' + + attrprotect(ufofields["openTypeNameManufacturerURL"]) + '" />\n') + file.write(' <credits>\n') + for credit in credits: + file.write(' <credit\n') + file.write(' name="' + attrprotect(credit["name"]) + '"\n') + if "url" in credit: file.write(' url="' + attrprotect(credit["url"]) + '"\n') + if "role" in credit: file.write(' role="' + attrprotect(credit["role"]) + '"\n') + file.write(' />\n') + file.write(' </credits>\n') + + if ufofields["woffMetadataDescriptionurl"]: + file.write(f' <description url="{ufofields["woffMetadataDescriptionurl"]}">\n') + else: + file.write(' <description>\n') + file.write(' <text lang="en">\n') + for entry in description: + for line in entry["text"].splitlines(): + file.write(' ' + textprotect(line) + '\n') + file.write(' </text>\n') + file.write(' </description>\n') + + file.write(' <license url="https://scripts.sil.org/OFL" id="org.sil.ofl.1.1">\n') + file.write(' <text lang="en">\n') + for line in ufofields["openTypeNameLicense"].splitlines(): file.write(' ' + textprotect(line) + '\n') + file.write(' </text>\n') + file.write(' </license>\n') + + file.write(' <copyright>\n') + file.write(' <text lang="en">\n') + for line in ufofields["copyright"].splitlines(): file.write(' ' + textprotect(line) + '\n') + file.write(' </text>\n') + file.write(' </copyright>\n') + + if 'trademark' in ufofields: + file.write(' <trademark>\n') + file.write(' <text lang="en">' + textprotect(ufofields["trademark"]) + '</text>\n') + file.write(' </trademark>\n') + + file.write('</metadata>') + + file.close() + +def readuntil(file, texts): # Read through file until line is in texts. Return section up to there and the text matched + skip = True + match = None + for line in file: + line = line.strip() + if skip: # Skip underlines and blank lines at start of section + if line == "" or line[0:5] == "-----": + pass + else: + section = line + skip = False + else: + for text in texts: + if line[0:len(text)] == text: match = text + if match: break + section = section + "\n" + line + while section[-1] == "\n": section = section[:-1] # Strip blank lines at end + return (section, match) + +def textprotect(txt): # Switch special characters in text to use &...; format + txt = re.sub(r'&', '&', txt) + txt = re.sub(r'<', '<', txt) + txt = re.sub(r'>', '>', txt) + return txt + +def attrprotect(txt): # Switch special characters in text to use &...; format + txt = re.sub(r'&', '&', txt) + txt = re.sub(r'<', '<', txt) + txt = re.sub(r'>', '>', txt) + txt = re.sub(r'"', '"', txt) + return txt + +def cmd(): execute("UFO", doit, argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfnormalize.py b/src/silfont/scripts/psfnormalize.py new file mode 100644 index 0000000..fa86dbf --- /dev/null +++ b/src/silfont/scripts/psfnormalize.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +__doc__ = '''Normalize a UFO and optionally convert between UFO2 and UFO3''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute + +argspec = [ + ('ifont',{'help': 'Input font file'}, {'type': 'infont'}), + ('ofont',{'help': 'Output font file','nargs': '?' }, {'type': 'outfont'}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': '_normalize.log'}), + ('-v','--version',{'help': 'UFO version to convert to (2, 3 or 3ff)'},{})] + +def doit(args) : + + if args.version is not None : + v = args.version.lower() + if v in ("2","3","3ff") : + if v == "3ff": # Special action for testing with FontForge import + v = "3" + args.ifont.outparams['format1Glifs'] = True + args.ifont.outparams['UFOversion'] = v + else: + args.logger.log("-v, --version must be one of 2,3 or 3ff", "S") + + return args.ifont + +def cmd() : execute("UFO",doit, argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfpreflightversion.py b/src/silfont/scripts/psfpreflightversion.py new file mode 100644 index 0000000..7dec547 --- /dev/null +++ b/src/silfont/scripts/psfpreflightversion.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +__doc__ = 'Display version info for pysilfont and dependencies, but only for preflight' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2023, SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +import sys +import importlib +import silfont + +def cmd(): + """gather the deps""" + + deps = ( # (module, used by, min recommended version) + ('defcon', '?', ''), + ('fontMath', '?', ''), + ('fontParts', '?', ''), + ('fontTools', '?', ''), + ('glyphConstruction', '?', ''), + ('glyphsLib', '?', ''), + ('lxml','?', ''), + ('mutatorMath', '?', ''), + ('palaso', '?', ''), + ('ufoLib2', '?', ''), + ) + + # Pysilfont info + print("Pysilfont " + silfont.__copyright__ + "\n") + print(" Version: " + silfont.__version__) + print(" Commands in: " + sys.argv[0][:-10]) + print(" Code running from: " + silfont.__file__[:-12]) + print(" using: Python " + sys.version.split(' \n', maxsplit=1)[0]) + + for dep in deps: + name = dep[0] + + try: + module = importlib.import_module(name) + path = module.__file__ + # Remove .py file name from end + pyname = path.split("/")[-1] + path = path[:-len(pyname)-1] + version = "No version info" + for attr in ("__version__", "version", "VERSION"): + if hasattr(module, attr): + version = getattr(module, attr) + break + except Exception as e: + etext = str(e) + if etext == "No module named '" + name + "'": + version = "Module is not installed" + else: + version = "Module import failed with " + etext + path = "" + + print('{:20} {:15} {}'.format(name + ":", version, path)) + +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfremovegliflibkeys.py b/src/silfont/scripts/psfremovegliflibkeys.py new file mode 100644 index 0000000..670275d --- /dev/null +++ b/src/silfont/scripts/psfremovegliflibkeys.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +__doc__ = '''Remove the specified key(s) from glif libs''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute + +argspec = [ + ('ifont',{'help': 'Input font file'}, {'type': 'infont'}), + ('key',{'help': 'Key(s) to remove','nargs': '*' }, {}), + ('-b', '--begins', {'help': 'Remove keys beginning with','nargs': '*' }, {}), + ('-o', '--ofont',{'help': 'Output font file' }, {'type': 'outfont'}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': '_removegliflibkeys.log'})] + +def doit(args) : + font = args.ifont + logger = args.logger + keys = args.key + bkeys=args.begins if args.begins is not None else [] + keycounts = {} + bkeycounts = {} + for key in keys : keycounts[key] = 0 + for key in bkeys: + if key in keycounts: logger.log("--begins key can't be the same as a standard key", "S") + bkeycounts[key] = 0 + + for glyphn in font.deflayer : + glyph = font.deflayer[glyphn] + if glyph["lib"] : + for key in keys : + if key in glyph["lib"] : + val = str( glyph["lib"].getval(key)) + glyph["lib"].remove(key) + keycounts[key] += 1 + logger.log(key + " removed from " + glyphn + ". Value was " + val, "I" ) + if key == "com.schriftgestaltung.Glyphs.originalWidth": # Special fix re glyphLib bug + if glyph["advance"] is None: glyph.add("advance") + adv = (glyph["advance"]) + if adv.width is None: + adv.width = int(float(val)) + logger.log("Advance width for " + glyphn + " set to " + val, "I") + else: + logger.log("Advance width for " + glyphn + " is already set to " + str(adv.width) + " so originalWidth not copied", "E") + for key in bkeys: + gkeys = list(glyph["lib"]) + for gkey in gkeys: + if gkey[:len(key)] == key: + val = str(glyph["lib"].getval(gkey)) + glyph["lib"].remove(gkey) + if gkey in keycounts: + keycounts[gkey] += 1 + else: + keycounts[gkey] = 1 + bkeycounts[key] += 1 + logger.log(gkey + " removed from " + glyphn + ". Value was " + val, "I") + + for key in keycounts : + count = keycounts[key] + if count > 0 : + logger.log(key + " removed from " + str(count) + " glyphs", "P") + else : + logger.log("No lib entries found for " + key, "E") + for key in bkeycounts: + if bkeycounts[key] == 0: logger.log("No lib entries found for beginning with " + key, "E") + + return font + +def cmd() : execute("UFO",doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfrenameglyphs.py b/src/silfont/scripts/psfrenameglyphs.py new file mode 100644 index 0000000..06cb3ce --- /dev/null +++ b/src/silfont/scripts/psfrenameglyphs.py @@ -0,0 +1,588 @@ +#!/usr/bin/env python3 +__doc__ = '''Assign new working names to glyphs based on csv input file +- csv format oldname,newname''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Bob Hallissy' + +from silfont.core import execute +from xml.etree import ElementTree as ET +import re +import os +from glob import glob + +argspec = [ + ('ifont',{'help': 'Input font file'}, {'type': 'infont'}), + ('ofont',{'help': 'Output font file','nargs': '?' }, {'type': 'outfont'}), + ('-c', '--classfile', {'help': 'Classes file'}, {}), + ('-i','--input',{'help': 'Input csv file'}, {'type': 'incsv', 'def': 'namemap.csv'}), + ('--mergecomps',{'help': 'turn on component merge', 'action': 'store_true', 'default': False},{}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': '_renameglyphs.log'})] + +csvmap = "" # Variable used globally + +def doit(args) : + global csvmap, ksetsbymember + font = args.ifont + incsv = args.input + incsv.numfields = 2 + logger = args.logger + mergemode = args.mergecomps + + failerrors = 0 # Keep count of errors that should cause the script to fail + csvmap = {} # List of all real maps in incsv, so excluding headers, blank lines, comments and identity maps + nameMap = {} # remember all glyphs actually renamed + kerngroupsrenamed = {} # List of all kern groups actually renamed + + # List of secondary layers (ie layers other than the default) + secondarylayers = [x for x in font.layers if x.layername != "public.default"] + + # Obtain lib.plist glyph order(s) and psnames if they exist: + publicGlyphOrder = csGlyphOrder = psnames = displayStrings = None + if hasattr(font, 'lib'): + if 'public.glyphOrder' in font.lib: + publicGlyphOrder = font.lib.getval('public.glyphOrder') # This is an array + if 'com.schriftgestaltung.glyphOrder' in font.lib: + csGlyphOrder = font.lib.getval('com.schriftgestaltung.glyphOrder') # This is an array + if 'public.postscriptNames' in font.lib: + psnames = font.lib.getval('public.postscriptNames') # This is a dict keyed by glyphnames + if 'com.schriftgestaltung.customParameter.GSFont.DisplayStrings' in font.lib: + displayStrings = font.lib.getval('com.schriftgestaltung.customParameter.GSFont.DisplayStrings') + else: + logger.log("no lib.plist found in font", "W") + + # Renaming within the UFO is done in two passes to make sure we can handle circular renames such as: + # someglyph.alt = someglyph + # someglyph = someglyph.alt + + # Note that the various objects with glyph names are all done independently since + # the same glyph names are not necessarily in all structures. + + # First pass: process all records of csv, and for each glyph that is to be renamed: + # If the new glyphname is not already present, go ahead and rename it now. + # If the new glyph name already exists, rename the glyph to a temporary name + # and put relevant details in saveforlater[] + + saveforlaterFont = [] # For the font itself + saveforlaterPGO = [] # For public.GlyphOrder + saveforlaterCSGO = [] # For GlyphsApp GlyphOrder (com.schriftgestaltung.glyphOrder) + saveforlaterPSN = [] # For public.postscriptNames + deletelater = [] # Glyphs we'll delete after merging + + for r in incsv: + oldname = r[0].strip() + newname = r[1].strip() + # ignore header row and rows where the newname is blank or a comment marker + if oldname == "Name" or oldname.startswith('#') or newname == "" or oldname == newname: + continue + if len(oldname)==0: + logger.log('empty glyph oldname in glyph_data; ignored (newname: %s)' % newname, 'W') + continue + csvmap[oldname]=newname + + # Handle font first: + if oldname not in font.deflayer: + logger.log("glyph name not in font: " + oldname , "I") + elif newname not in font.deflayer: + inseclayers = False + for layer in secondarylayers: + if newname in layer: + logger.log("Glyph %s is already in non-default layers; can't rename %s" % (newname, oldname), "E") + failerrors += 1 + inseclayers = True + continue + if not inseclayers: + # Ok, this case is easy: just rename the glyph in all layers + for layer in font.layers: + if oldname in layer: layer[oldname].name = newname + nameMap[oldname] = newname + logger.log("Pass 1 (Font): Renamed %s to %s" % (oldname, newname), "I") + elif mergemode: + mergeglyphs(font.deflayer[oldname], font.deflayer[newname]) + for layer in secondarylayers: + if oldname in layer: + if newname in layer: + mergeglyphs(layer[oldname], layer[newname]) + else: + layer[oldname].name = newname + + nameMap[oldname] = newname + deletelater.append(oldname) + logger.log("Pass 1 (Font): merged %s to %s" % (oldname, newname), "I") + else: + # newname already in font -- but it might get renamed later in which case this isn't actually a problem. + # For now, then, rename glyph to a temporary name and remember it for second pass + tempname = gettempname(lambda n : n not in font.deflayer) + for layer in font.layers: + if oldname in layer: + layer[oldname].name = tempname + saveforlaterFont.append( (tempname, oldname, newname) ) + + # Similar algorithm for public.glyphOrder, if present: + if publicGlyphOrder: + if oldname not in publicGlyphOrder: + logger.log("glyph name not in publicGlyphorder: " + oldname , "I") + else: + x = publicGlyphOrder.index(oldname) + if newname not in publicGlyphOrder: + publicGlyphOrder[x] = newname + nameMap[oldname] = newname + logger.log("Pass 1 (PGO): Renamed %s to %s" % (oldname, newname), "I") + elif mergemode: + del publicGlyphOrder[x] + nameMap[oldname] = newname + logger.log("Pass 1 (PGO): Removed %s (now using %s)" % (oldname, newname), "I") + else: + tempname = gettempname(lambda n : n not in publicGlyphOrder) + publicGlyphOrder[x] = tempname + saveforlaterPGO.append( (x, oldname, newname) ) + + # And for GlyphsApp glyph order, if present: + if csGlyphOrder: + if oldname not in csGlyphOrder: + logger.log("glyph name not in csGlyphorder: " + oldname , "I") + else: + x = csGlyphOrder.index(oldname) + if newname not in csGlyphOrder: + csGlyphOrder[x] = newname + nameMap[oldname] = newname + logger.log("Pass 1 (csGO): Renamed %s to %s" % (oldname, newname), "I") + elif mergemode: + del csGlyphOrder[x] + nameMap[oldname] = newname + logger.log("Pass 1 (csGO): Removed %s (now using %s)" % (oldname, newname), "I") + else: + tempname = gettempname(lambda n : n not in csGlyphOrder) + csGlyphOrder[x] = tempname + saveforlaterCSGO.append( (x, oldname, newname) ) + + # And for psnames + if psnames: + if oldname not in psnames: + logger.log("glyph name not in psnames: " + oldname , "I") + elif newname not in psnames: + psnames[newname] = psnames.pop(oldname) + nameMap[oldname] = newname + logger.log("Pass 1 (psn): Renamed %s to %s" % (oldname, newname), "I") + elif mergemode: + del psnames[oldname] + nameMap[oldname] = newname + logger.log("Pass 1 (psn): Removed %s (now using %s)" % (oldname, newname), "I") + else: + tempname = gettempname(lambda n: n not in psnames) + psnames[tempname] = psnames.pop(oldname) + saveforlaterPSN.append( (tempname, oldname, newname)) + + # Second pass: now we can reprocess those things we saved for later: + # If the new glyphname is no longer present, we can complete the renaming + # Otherwise we've got a fatal error + + for j in saveforlaterFont: + tempname, oldname, newname = j + if newname in font.deflayer: # Only need to check deflayer, since (if present) it would have been renamed in all + # Ok, this really is a problem + logger.log("Glyph %s already in font; can't rename %s" % (newname, oldname), "E") + failerrors += 1 + else: + for layer in font.layers: + if tempname in layer: + layer[tempname].name = newname + nameMap[oldname] = newname + logger.log("Pass 2 (Font): Renamed %s to %s" % (oldname, newname), "I") + + for j in saveforlaterPGO: + x, oldname, newname = j + if newname in publicGlyphOrder: + # Ok, this really is a problem + logger.log("Glyph %s already in public.GlyphOrder; can't rename %s" % (newname, oldname), "E") + failerrors += 1 + else: + publicGlyphOrder[x] = newname + nameMap[oldname] = newname + logger.log("Pass 2 (PGO): Renamed %s to %s" % (oldname, newname), "I") + + for j in saveforlaterCSGO: + x, oldname, newname = j + if newname in csGlyphOrder: + # Ok, this really is a problem + logger.log("Glyph %s already in com.schriftgestaltung.glyphOrder; can't rename %s" % (newname, oldname), "E") + failerrors += 1 + else: + csGlyphOrder[x] = newname + nameMap[oldname] = newname + logger.log("Pass 2 (csGO): Renamed %s to %s" % (oldname, newname), "I") + + for tempname, oldname, newname in saveforlaterPSN: + if newname in psnames: + # Ok, this really is a problem + logger.log("Glyph %s already in public.postscriptNames; can't rename %s" % (newname, oldname), "E") + failerrors += 1 + else: + psnames[newname] = psnames.pop(tempname) + nameMap[oldname] = newname + logger.log("Pass 2 (psn): Renamed %s to %s" % (oldname, newname), "I") + + # Rebuild font structures from the modified lists we have: + + # Rebuild glyph order elements: + if publicGlyphOrder: + array = ET.Element("array") + for name in publicGlyphOrder: + ET.SubElement(array, "string").text = name + font.lib.setelem("public.glyphOrder", array) + + if csGlyphOrder: + array = ET.Element("array") + for name in csGlyphOrder: + ET.SubElement(array, "string").text = name + font.lib.setelem("com.schriftgestaltung.glyphOrder", array) + + # Rebuild postscriptNames: + if psnames: + dict = ET.Element("dict") + for n in psnames: + ET.SubElement(dict, "key").text = n + ET.SubElement(dict, "string").text = psnames[n] + font.lib.setelem("public.postscriptNames", dict) + + # Iterate over all glyphs, and fix up any components that reference renamed glyphs + for layer in font.layers: + for name in layer: + glyph = layer[name] + for component in glyph.etree.findall('./outline/component[@base]'): + oldname = component.get('base') + if oldname in nameMap: + component.set('base', nameMap[oldname]) + logger.log(f'renamed component base {oldname} to {component.get("base")} in glyph {name} layer {layer.layername}', 'I') + lib = glyph['lib'] + if lib: + if 'com.schriftgestaltung.Glyphs.ComponentInfo' in lib: + cielem = lib['com.schriftgestaltung.Glyphs.ComponentInfo'][1] + for component in cielem: + for i in range(0,len(component),2): + if component[i].text == 'name': + oldname = component[i+1].text + if oldname in nameMap: + component[i+1].text = nameMap[oldname] + logger.log(f'renamed component info {oldname} to {nameMap[oldname]} in glyph {name} layer {layer.layername}', 'I') + + # Delete anything we no longer need: + for name in deletelater: + for layer in font.layers: + if name in layer: layer.delGlyph(name) + logger.log("glyph %s removed" % name, "I") + + # Other structures with glyphs in are handled by looping round the structures replacing glyphs rather than + # looping round incsv + + # Update Display Strings + + if displayStrings: + changed = False + glyphRE = re.compile(r'/([a-zA-Z0-9_.-]+)') # regex to match / followed by a glyph name + for i, dispstr in enumerate(displayStrings): # Passing the glyphSub function to .sub() causes it to + displayStrings[i] = glyphRE.sub(glyphsub, dispstr) # every non-overlapping occurrence of pattern + if displayStrings[i] != dispstr: + changed = True + if changed: + array = ET.Element("array") + for dispstr in displayStrings: + ET.SubElement(array, "string").text = dispstr + font.lib.setelem('com.schriftgestaltung.customParameter.GSFont.DisplayStrings', array) + logger.log("com.schriftgestaltung.customParameter.GSFont.DisplayStrings updated", "I") + + # Process groups.plist and kerning.plist + # group names in the form public.kern[1|2].<glyph name> will automatically be renamed if the glyph name is in the csvmap + # + groups = kerning = None + kgroupprefixes = {"public.kern1.": 1, "public.kern2.": 2} + + if "groups" in font.__dict__: groups = font.groups + if "kerning" in font.__dict__: kerning = font.kerning + + if (groups or kerning) and mergemode: + logger.log("Note - Kerning and group data not processed when using mergecomps", "P") + elif groups or kerning: + + kgroupsmap = ["", {}, {}] # Dicts of kern1/kern2 group renames. Outside the groups if statement, since also used with kerning.plist + if groups: + # Analyse existing data, building dict from existing data and building some indexes + gdict = {} + kgroupsbyglyph = ["", {}, {}] # First entry dummy, so index is 1 or 2 for kern1 and kern2 + kgroupduplicates = ["", [], []] # + for gname in groups: + group = groups.getval(gname) + gdict[gname] = group + kprefix = gname[0:13] + if kprefix in kgroupprefixes: + ktype = kgroupprefixes[kprefix] + for glyph in group: + if glyph in kgroupsbyglyph[ktype]: + kgroupduplicates[ktype].append(glyph) + logger.log("In existing kern groups, %s is in more than one kern%s group" % (glyph, str(ktype)), "E") + failerrors += 1 + else: + kgroupsbyglyph[ktype][glyph] = gname + # Now process the group data + glyphsrenamed = [] + saveforlaterKgroups = [] + for gname in list(gdict): # Loop round groups renaming glyphs within groups and kern group names + group = gdict[gname] + + # Rename group if kern1 or kern2 group + kprefix = gname[:13] + if kprefix in kgroupprefixes: + ktype = kgroupprefixes[kprefix] + ksuffix = gname[13:] + if ksuffix in csvmap: # This is a kern group that we should rename + newgname = kprefix + csvmap[ksuffix] + if newgname in gdict: # Will need to be renamed in second pass + tempname = gettempname(lambda n : n not in gdict) + gdict[tempname] = gdict.pop(gname) + saveforlaterKgroups.append((tempname, gname, newgname)) + else: + gdict[newgname] = gdict.pop(gname) + kerngroupsrenamed[gname] = newgname + logger.log("Pass 1 (Kern groups): Renamed %s to %s" % (gname, newgname), "I") + kgroupsmap[ktype][gname] = newgname + + # Now rename glyphs within the group + # - This could lead to duplicate names, but that might be valid for arbitrary groups so not checked + # - kern group validity will be checked after all renaming is done + + for (i, glyph) in enumerate(group): + if glyph in csvmap: + group[i] = csvmap[glyph] + if glyph not in glyphsrenamed: glyphsrenamed.append(glyph) + + # Need to report glyphs renamed after the loop, since otherwise could report multiple times + for oldname in glyphsrenamed: + nameMap[oldname] = csvmap[oldname] + logger.log("Glyphs in groups: Renamed %s to %s" % (oldname, csvmap[oldname]), "I") + + # Second pass for renaming kern groups. (All glyph renaming is done in first pass) + + for (tempname, oldgname, newgname) in saveforlaterKgroups: + if newgname in gdict: # Can't rename + logger.log("Kern group %s already in groups.plist; can't rename %s" % (newgname, oldgname), "E") + failerrors += 1 + else: + gdict[newgname] = gdict.pop(tempname) + kerngroupsrenamed[oldgname] = newgname + logger.log("Pass 2 (Kern groups): Renamed %s to %s" % (oldgname, newgname), "I") + + # Finally check kern groups follow the UFO rules! + kgroupsbyglyph = ["", {}, {}] # Reset for new analysis + for gname in gdict: + group = gdict[gname] + kprefix = gname[:13] + if kprefix in kgroupprefixes: + ktype = kgroupprefixes[kprefix] + for glyph in group: + if glyph in kgroupsbyglyph[ktype]: # Glyph already in a kern group so we have a duplicate + if glyph not in kgroupduplicates[ktype]: # This is a newly-created duplicate so report + logger.log("After renaming, %s is in more than one kern%s group" % (glyph, str(ktype)), "E") + failerrors += 1 + kgroupduplicates[ktype].append(glyph) + else: + kgroupsbyglyph[ktype][glyph] = gname + + # Now need to recreate groups.plist from gdict + + for group in list(groups): groups.remove(group) # Empty existing contents + for gname in gdict: + elem = ET.Element("array") + for glyph in gdict[gname]: + ET.SubElement(elem, "string").text = glyph + groups.setelem(gname, elem) + + # Now process kerning data + if kerning: + k1map = kgroupsmap[1] + k2map = kgroupsmap[2] + kdict = {} + for setname in kerning: kdict[setname] = kerning.getval(setname) # Create a working dict from plist + saveforlaterKsets = [] + # First pass on set names + for setname in list(kdict): # setname could be a glyph in csvmap or a kern1 group name in k1map + if setname in csvmap or setname in k1map: + newname = csvmap[setname] if setname in csvmap else k1map[setname] + if newname in kdict: + tempname = gettempname(lambda n : n not in kdict) + kdict[tempname] = kdict.pop(setname) + saveforlaterKsets.append((tempname, setname, newname)) + else: + kdict[newname] = kdict.pop(setname) + if setname in csvmap: nameMap[setname] = newname # Change to kern set name will have been logged previously + logger.log("Pass 1 (Kern sets): Renamed %s to %s" % (setname, newname), "I") + + # Now do second pass for set names + for (tempname, oldname, newname) in saveforlaterKsets: + if newname in kdict: # Can't rename + logger.log("Kern set %s already in kerning.plist; can't rename %s" % (newname, oldname), "E") + failerrors += 1 + else: + kdict[newname] = kdict.pop(tempname) + if oldname in csvmap: nameMap[oldname] = newname + logger.log("Pass 1 (Kern sets): Renamed %s to %s" % (oldname, newname), "I") + + # Rename kern set members next. + + # Here, since a member could be in more than one set, take different approach to two passes. + # - In first pass, rename to a temp (and invalid) name so duplicates are not possible. Name to include + # old name for reporting purposes + # - In second pass, set to correct new name after checking for duplicates + + # Do first pass for set names + tempnames = [] + for setname in list(kdict): + kset = kdict[setname] + + for mname in list(kset): # mname could be a glyph in csvmap or a kern2 group name in k2map + if mname in csvmap or mname in k2map: + newname = csvmap[mname] if mname in csvmap else k2map[mname] + newname = "^" + newname + "^" + mname + if newname not in tempnames: tempnames.append(newname) + kset[newname] = kset.pop(mname) + + # Second pass to change temp names to correct final names + # We need an index of which sets each member is in + ksetsbymember = {} + for setname in kdict: + kset = kdict[setname] + for member in kset: + if member not in ksetsbymember: + ksetsbymember[member] = [setname] + else: + ksetsbymember[member].append(setname) + # Now do the renaming + for tname in tempnames: + (newname, oldname) = tname[1:].split("^") + if newname in ksetsbymember: # Can't rename + logger.log("Kern set %s already in kerning.plist; can't rename %s" % (newname, oldname), "E") + failerrors += 1 + else: + for ksetname in ksetsbymember[tname]: + kset = kdict[ksetname] + kset[newname] = kset.pop(tname) + ksetsbymember[newname] = ksetsbymember.pop(tname) + if tname in csvmap: nameMap[oldname] = newname + logger.log("Kern set members: Renamed %s to %s" % (oldname, newname), "I") + + # Now need to recreate kerning.plist from kdict + for kset in list(kerning): kerning.remove(kset) # Empty existing contents + for kset in kdict: + elem = ET.Element("dict") + for member in kdict[kset]: + ET.SubElement(elem, "key").text = member + ET.SubElement(elem, "integer").text = str(kdict[kset][member]) + kerning.setelem(kset, elem) + + if failerrors: + logger.log(str(failerrors) + " issues detected - see errors reported above", "S") + + logger.log("%d glyphs renamed in UFO" % (len(nameMap)), "P") + if kerngroupsrenamed: logger.log("%d kern groups renamed in UFO" % (len(kerngroupsrenamed)), "P") + + # If a classfile was provided, change names within it also + # + if args.classfile: + + logger.log("Processing classfile {}".format(args.classfile), "P") + + # In order to preserve comments we use our own TreeBuilder + class MyTreeBuilder(ET.TreeBuilder): + def comment(self, data): + self.start(ET.Comment, {}) + self.data(data) + self.end(ET.Comment) + + # RE to match separators between glyph names (whitespace): + notGlyphnameRE = re.compile(r'(\s+)') + + # Keep a list of glyphnames that were / were not changed + changed = set() + notChanged = set() + + # Process one token (might be whitespace separator, glyph name, or embedded classname starting with @): + def dochange(gname, logErrors = True): + if len(gname) == 0 or gname.isspace() or gname not in csvmap or gname.startswith('@'): + # No change + return gname + try: + newgname = csvmap[gname] + changed.add(gname) + return newgname + except KeyError: + if logErrors: notChanged.add(gname) + return gname + + doc = ET.parse(args.classfile, parser=ET.XMLParser(target=MyTreeBuilder())) + for e in doc.iter(None): + if e.tag in ('class', 'property'): + if 'exts' in e.attrib: + logger.log("{} '{}' has 'exts' attribute which may need editing".format(e.tag.title(), e.get('name')), "W") + # Rather than just split() the text, we'll use re and thus try to preserve whitespace + e.text = ''.join([dochange(x) for x in notGlyphnameRE.split(e.text)]) + elif e.tag is ET.Comment: + # Go ahead and look for glyph names in comment text but don't flag as error + e.text = ''.join([dochange(x, False) for x in notGlyphnameRE.split(e.text)]) + # and process the tail as this might be valid part of class or property + e.tail = ''.join([dochange(x) for x in notGlyphnameRE.split(e.tail)]) + + + if len(changed): + # Something in classes changed so rewrite it... saving backup + (dn,fn) = os.path.split(args.classfile) + dn = os.path.join(dn, args.paramsobj.sets['main']['backupdir']) + if not os.path.isdir(dn): + os.makedirs(dn) + # Work out backup name based on existing backups + backupname = os.path.join(dn,fn) + nums = [int(re.search(r'\.(\d+)~$',n).group(1)) for n in glob(backupname + ".*~")] + backupname += ".{}~".format(max(nums) + 1 if nums else 1) + logger.log("Backing up input classfile to {}".format(backupname), "P") + # Move the original file to backupname + os.rename(args.classfile, backupname) + # Write the output file + doc.write(args.classfile) + + if len(notChanged): + logger.log("{} glyphs renamed, {} NOT renamed in {}: {}".format(len(changed), len(notChanged), args.classfile, ' '.join(notChanged)), "W") + else: + logger.log("All {} glyphs renamed in {}".format(len(changed), args.classfile), "P") + + return font + +def mergeglyphs(mergefrom, mergeto): # Merge any "moving" anchors (i.e., those starting with '_') into the glyph we're keeping + # Assumption: we are merging one or more component references to just one component; deleting the others + for a in mergefrom['anchor']: + aname = a.element.get('name') + if aname.startswith('_'): + # We want to copy this anchor to the glyph being kept: + for i, a2 in enumerate(mergeto['anchor']): + if a2.element.get('name') == aname: + # Overwrite existing anchor of same name + mergeto['anchor'][i] = a + break + else: + # Append anchor to glyph + mergeto['anchor'].append(a) + +def gettempname(f): + ''' return a temporary glyph name that, when passed to function f(), returns true''' + # Initialize function attribute for use as counter + if not hasattr(gettempname, "counter"): gettempname.counter = 0 + while True: + name = "tempglyph%d" % gettempname.counter + gettempname.counter += 1 + if f(name): return name + +def glyphsub(m): # Function passed to re.sub() when updating display strings + global csvmap + gname = m.group(1) + return '/' + csvmap[gname] if gname in csvmap else m.group(0) + +def cmd() : execute("UFO",doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfrunfbchecks.py b/src/silfont/scripts/psfrunfbchecks.py new file mode 100644 index 0000000..69ff192 --- /dev/null +++ b/src/silfont/scripts/psfrunfbchecks.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +'''Run Font Bakery tests using a standard profile with option to specify an alternative profile +It defaults to ttfchecks.py - ufo checks are not supported yet''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2020 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +import glob, os, csv + +from textwrap import TextWrapper + +# Error message for users installing pysilfont manually +try: + import fontbakery +except ImportError: + print("\nError: Fontbakery is not installed by default, type pip3 install fontbakery[all]\n") +else: + from fontbakery.reporters.serialize import SerializeReporter + from fontbakery.reporters.html import HTMLReporter + from fontbakery.checkrunner import distribute_generator, CheckRunner, get_module_profile + from fontbakery.status import PASS, FAIL, WARN, ERROR, INFO, SKIP + from fontbakery.configuration import Configuration + from fontbakery.commands.check_profile import get_module + from fontbakery import __version__ as version + +from silfont.core import execute + +argspec = [ + ('fonts',{'help': 'font(s) to run checks against; wildcards allowed', 'nargs': "+"}, {'type': 'filename'}), + ('--profile', {'help': 'profile to use instead of Pysilfont default'}, {}), + ('--html', {'help': 'Write html report to htmlfile', 'metavar': "HTMLFILE"}, {}), + ('--csv',{'help': 'Write results to csv file'}, {'type': 'filename', 'def': None}), + ('-F', '--full-lists',{'help': "Don't truncate lists of items" ,'action': 'store_true', 'default': False}, {}), + ('--ttfaudit', {'help': 'Compare the list of ttf checks in pysilfont with those in Font Bakery and output a csv to "fonts". No checks are actually run', + 'action': 'store_true', 'default': False}, {}), + ('-l', '--log', {'help': 'Log file'}, {'type': 'outfile', 'def': '_runfbchecks.log'})] + +def doit(args): + global version + v = version.split(".") + version = f'{v[0]}.{v[1]}.{v[2]}' # Set version to just the number part - ie without .dev... + + logger = args.logger + htmlfile = args.html + + if args.ttfaudit: # Special action to compare checks in profile against check_list values + audit(args.fonts, logger) # args.fonts used as output file name for audit + return + + if args.csv: + try: + csvfile = open(args.csv, 'w') + csvwriter = csv.writer(csvfile) + csvlines = [] + except Exception as e: + logger.log("Failed to open " + args.csv + ": " + str(e), "S") + else: + csvfile = None + + # Process list of fonts supplied, expanding wildcards using glob if needed + fonts = [] + fontstype = None + for pattern in args.fonts: + for fullpath in glob.glob(pattern): + ftype = fullpath.lower().rsplit(".", 1)[-1] + if ftype == "otf": ftype = "ttf" + if ftype not in ("ttf", "ufo"): + logger.log("Fonts must be OpenType or UFO - " + fullpath + " invalid", "S") + if fontstype is None: + fontstype = ftype + else: + if ftype != fontstype: + logger.log("All fonts must be of the same type - both UFO and ttf/otf fonts supplied", "S") + fonts.append(fullpath) + + if fonts == [] : logger.log("No files match the filespec provided for fonts: " + str(args.fonts), "S") + + # Find the main folder name for ttf files - strips "results" if present + (path, ttfdir) = os.path.split(os.path.dirname(fonts[0])) + if ttfdir == ("results"): ttfdir = os.path.basename(path) + + # Create the profile object + if args.profile: + proname = args.profile + else: + if fontstype == "ttf": + proname = "silfont.fbtests.ttfchecks" + else: + logger.log("UFO fonts not yet supported", "S") + + try: + module = get_module(proname) + except Exception as e: + logger.log("Failed to import profile: " + proname + "\n" + str(e), "S") + + profile = get_module_profile(module) + profile.configuration_defaults = { + "com.google.fonts/check/file_size": { + "WARN_SIZE": 1 * 1024 * 1024, + "FAIL_SIZE": 9 * 1024 * 1024 + } + } + + psfcheck_list = module.psfcheck_list + + # Create the runner and reporter objects, then run the tests + configuration = Configuration(full_lists = args.full_lists) + runner = CheckRunner(profile, values={ + "fonts": fonts, 'ufos': [], 'designspaces': [], 'glyphs_files': [], 'readme_md': [], 'metadata_pb': []} + , config=configuration) + + if version == "0.8.6": + sr = SerializeReporter(runner=runner) # This produces results from all the tests in sr.getdoc for later analysis + else: + sr = SerializeReporter(runner=runner, loglevels = [INFO]) # loglevels was added with 0.8.7 + reporters = [sr.receive] + + if htmlfile: + hr = HTMLReporter(runner=runner, loglevels = [SKIP]) + reporters.append(hr.receive) + + distribute_generator(runner.run(), reporters) + + # Process the results + results = sr.getdoc() + sections = results["sections"] + + checks = {} + maxname = 11 + somedebug = False + overrides = {} + tempoverrides = False + + for section in sections: + secchecks = section["checks"] + for check in secchecks: + checkid = check["key"][1][17:-1] + fontfile = check["filename"] if "filename" in check else "Family-wide" + path, fontname = os.path.split(fontfile) + if fontname not in checks: + checks[fontname] = {"ERROR": [], "FAIL": [], "WARN": [], "INFO": [], "SKIP": [], "PASS": [], "DEBUG": []} + if len(fontname) > maxname: maxname = len(fontname) + status = check["result"] + if checkid in psfcheck_list: + # Look for status overrides + (changetype, temp) = ("temp_change_status", True) if "temp_change_status" in psfcheck_list[checkid]\ + else ("change_status", False) + if changetype in psfcheck_list[checkid]: + change_status = psfcheck_list[checkid][changetype] + if status in change_status: + reason = change_status["reason"] if "reason" in change_status else None + overrides[fontname + ", " + checkid] = (status + " to " + change_status[status], temp, reason) + if temp: tempoverrides = True + status = change_status[status] ## Should validate new status is one of FAIL, WARN or PASS + checks[fontname][status].append(check) + if status == "DEBUG": somedebug = True + + if htmlfile: + logger.log("Writing results to " + htmlfile, "P") + with open(htmlfile, 'w') as hfile: + hfile.write(hr.get_html()) + + fbstats = ["ERROR", "FAIL", "WARN", "INFO", "SKIP", "PASS"] + psflevels = ["E", "E", "W", "I", "I", "V"] + if somedebug: # Only have debug column if some debug statuses are present + fbstats.append("DEBUG") + psflevels.append("W") + wrapper = TextWrapper(width=120, initial_indent=" ", subsequent_indent=" ") + errorcnt = 0 + failcnt = 0 + summarymess = "Check status summary:\n" + summarymess += "{:{pad}}ERROR FAIL WARN INFO SKIP PASS".format("", pad=maxname+4) + if somedebug: summarymess += " DEBUG" + fontlist = list(sorted(x for x in checks if x != "Family-wide")) # Alphabetic list of fonts + if "Family-wide" in checks: fontlist.append("Family-wide") # Add Family-wide last + for fontname in fontlist: + summarymess += "\n {:{pad}}".format(fontname, pad=maxname) + for i, status in enumerate(fbstats): + psflevel = psflevels[i] + checklist = checks[fontname][status] + cnt = len(checklist) + if cnt > 0 or status != "DEBUG": summarymess += "{:6d}".format(cnt) # Suppress 0 for DEBUG + if cnt: + if status == "ERROR": errorcnt += cnt + if status == "FAIL": failcnt += cnt + messparts = ["Checks with status {} for {}".format(status, fontname)] + for check in checklist: + checkid = check["key"][1][17:-1] + csvline = [ttfdir, fontname, check["key"][1][17:-1], status, check["description"]] + messparts.append(" > {}".format(checkid)) + for record in check["logs"]: + message = record["message"] + if record["status"] != status: message = record["status"] + " " + message + messparts += wrapper.wrap(message) + csvline.append(message) + if csvfile: csvlines.append(csvline) + logger.log("\n".join(messparts) , psflevel) + if csvfile: # Output to csv file, worted by font then checkID + for line in sorted(csvlines, key = lambda x: (x[1],x[2])): csvwriter.writerow(line) + if overrides != {}: + summarymess += "\n Note: " + str(len(overrides)) + " Fontbakery statuses were overridden - see log file for details" + if tempoverrides: summarymess += "\n ******** Some of the overrides were temporary overrides ********" + logger.log(summarymess, "P") + + if overrides != {}: + for oname in overrides: + override = overrides[oname] + mess = "Status override for " + oname + ": " + override[0] + if override[1]: mess += " (Temporary override)" + logger.log(mess, "W") + if override[2] is not None: logger.log("Override reason: " + override[2], "I") + + if errorcnt + failcnt > 0: + mess = str(failcnt) + " test(s) gave a status of FAIL" if failcnt > 0 else "" + if errorcnt > 0: + if failcnt > 0: mess += "\n " + mess += str(errorcnt) + " test(s) gave a status of ERROR which means they failed to execute properly." \ + "\n " \ + " ERROR probably indicates a software issue rather than font issue" + logger.log(mess, "E") + +def audit(fonts, logger): + if len(fonts) != 1: logger.log("For audit, specify output csv file instead of list of fonts", "S") + csvname = fonts[0] + from silfont.fbtests.ttfchecks import all_checks_dict + missingfromprofile=[] + missingfromchecklist=[] + checks = all_checks_dict() + logger.log("Opening " + csvname + " for audit output csv", "P") + with open(csvname, 'w', newline='') as csvfile: + csvwriter = csv.writer(csvfile, dialect='excel') + fields = ["id", "psfaction", "section", "description", "rationale", "conditions"] + csvwriter.writerow(fields) + + for checkid in checks: + check = checks[checkid] + row = [checkid] + for field in fields: + if field != "id": row.append(check[field]) + if check["section"] == "Missing": missingfromprofile.append(checkid) + if check["psfaction"] == "Not in psfcheck_list": missingfromchecklist.append(checkid) + csvwriter.writerow(row) + if missingfromprofile != []: + mess = "The following checks are in psfcheck_list but not in the ttfchecks.py profile:" + for checkid in missingfromprofile: mess += "\n " + checkid + logger.log(mess, "E") + if missingfromchecklist != []: + mess = "The following checks are in the ttfchecks.py profile but not in psfcheck_list:" + for checkid in missingfromchecklist: mess += "\n " + checkid + logger.log(mess, "E") + + return + +def cmd(): execute(None, doit, argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfsetassocfeat.py b/src/silfont/scripts/psfsetassocfeat.py new file mode 100644 index 0000000..b0ccb86 --- /dev/null +++ b/src/silfont/scripts/psfsetassocfeat.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +__doc__ = '''Add associate feature info to glif lib based on a csv file +csv format glyphname,featurename[,featurevalue]''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute + +suffix = "_AssocFeat" +argspec = [ + ('ifont',{'help': 'Input font file'}, {'type': 'infont'}), + ('ofont',{'help': 'Output font file','nargs': '?' }, {'type': 'outfont'}), + ('-i','--input',{'help': 'Input csv file'}, {'type': 'incsv', 'def': suffix+'.csv'}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': suffix+'.log'})] + +def doit(args) : + font = args.ifont + incsv = args.input + incsv.minfields = 2 + incsv.maxfields = 3 + incsv.logger = font.logger + glyphlist = list(font.deflayer.keys()) # Identify which glifs have not got an AssocFeat set + + for line in incsv : + glyphn = line[0] + feature = line[1] + value = line[2] if len(line) == 3 else "" + + if glyphn in glyphlist : + glyph = font.deflayer[glyphn] + if glyph["lib"] is None : glyph.add("lib") + glyph["lib"].setval("org.sil.assocFeature","string",feature) + if value != "" : + glyph["lib"].setval("org.sil.assocFeatureValue","integer",value) + else : + if "org.sil.assocFeatureValue" in glyph["lib"] : glyph["lib"].remove("org.sil.assocFeatureValue") + glyphlist.remove(glyphn) + else : + font.logger.log("No glyph in font for " + glyphn + " on line " + str(incsv.line_num),"E") + + for glyphn in glyphlist : # Remove any values from remaining glyphs + glyph = font.deflayer[glyphn] + if glyph["lib"] : + if "org.sil.assocFeatureValue" in glyph["lib"] : glyph["lib"].remove("org.sil.assocFeatureValue") + if "org.sil.assocFeature" in glyph["lib"] : + glyph["lib"].remove("org.sil.assocFeature") + font.logger.log("Feature info removed for " + glyphn,"I") + + return font + +def cmd() : execute("UFO",doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfsetassocuids.py b/src/silfont/scripts/psfsetassocuids.py new file mode 100644 index 0000000..43775d5 --- /dev/null +++ b/src/silfont/scripts/psfsetassocuids.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +__doc__ = '''Add associate UID info to glif lib based on a csv file +- Could be one value for variant UIDs and multiple for ligatures''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute +from xml.etree import ElementTree as ET + +suffix = "_AssocUIDs" +argspec = [ + ('ifont',{'help': 'Input font file'}, {'type': 'infont'}), + ('ofont',{'help': 'Output font file','nargs': '?' }, {'type': 'outfont'}), + ('-i','--input',{'help': 'Input csv file'}, {'type': 'incsv', 'def': suffix+'.csv'}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': suffix+'.log'})] + +def doit(args) : + font = args.ifont + incsv = args.input + incsv.minfields = 2 + incsv.logger = font.logger + glyphlist = list(font.deflayer.keys()) # Identify which glifs have not got AssocUIDs set + + for line in incsv : + glyphn = line.pop(0) + if glyphn in glyphlist : + glyph = font.deflayer[glyphn] + if glyph["lib"] is None : glyph.add("lib") + # Create an array element for the UID value(s) + array = ET.Element("array") + for UID in line: + sub = ET.SubElement(array,"string") + sub.text = UID + glyph["lib"].setelem("org.sil.assocUIDs",array) + glyphlist.remove(glyphn) + else : + font.logger.log("No glyph in font for " + glyphn + " on line " + str(incsv.line_num),"E") + + for glyphn in glyphlist : # Remove any values from remaining glyphs + glyph = font.deflayer[glyphn] + if glyph["lib"] : + if "org.sil.assocUIDs" in glyph["lib"] : + glyph["lib"].remove("org.sil.assocUIDs") + font.logger.log("UID info removed for " + glyphn,"I") + + return font + +def cmd() : execute("UFO",doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfsetdummydsig.py b/src/silfont/scripts/psfsetdummydsig.py new file mode 100644 index 0000000..bf7973c --- /dev/null +++ b/src/silfont/scripts/psfsetdummydsig.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 + +__doc__ = 'Put a dummy DSIG table into a ttf font' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Nicolas Spalinger' + +from silfont.core import execute +from fontTools import ttLib + +argspec = [ + ('-i', '--ifont', {'help': 'Input ttf font file'}, {}), + ('-o', '--ofont', {'help': 'Output font file'}, {}), + ('-l', '--log', {'help': 'Optional log file'}, {'type': 'outfile', 'def': 'dummydsig.log', 'optlog': True})] + + +def doit(args): + + ttf = ttLib.TTFont(args.ifont) + + newDSIG = ttLib.newTable("DSIG") + newDSIG.ulVersion = 1 + newDSIG.usFlag = 0 + newDSIG.usNumSigs = 0 + newDSIG.signatureRecords = [] + ttf.tables["DSIG"] = newDSIG + + args.logger.log('Saving the output ttf file with dummy DSIG table', 'P') + ttf.save(args.ofont) + + args.logger.log('Done', 'P') + + +def cmd(): execute("FT", doit, argspec) +if __name__ == '__main__': cmd() diff --git a/src/silfont/scripts/psfsetglyphdata.py b/src/silfont/scripts/psfsetglyphdata.py new file mode 100644 index 0000000..a693e5a --- /dev/null +++ b/src/silfont/scripts/psfsetglyphdata.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +__doc__ = '''Update and/or sort glyph_data.csv based on input file(s)''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2021 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute +import csv + +argspec = [ + ('glyphdata', {'help': 'glyph_data csv file to update'}, {'type': 'incsv', 'def': 'glyph_data.csv'}), + ('outglyphdata', {'help': 'Alternative output file name', 'nargs': '?'}, {'type': 'filename', 'def': None}), + ('-a','--addcsv',{'help': 'Records to add to glyphdata'}, {'type': 'incsv', 'def': None}), + ('-d', '--deletions', {'help': 'Records to delete from glyphdata'}, {'type': 'incsv', 'def': None}), + ('-s', '--sortheader', {'help': 'Column header to sort by'}, {}), + ('--sortalpha', {'help': 'Use with sortheader to sort alphabetically not numerically', 'action': 'store_true', 'default': False}, {}), + ('-f', '--force', {'help': 'When adding, if glyph exists, overwrite existing data', 'action': 'store_true', 'default': False}, {}), + ('-l','--log',{'help': 'Log file name'}, {'type': 'outfile', 'def': 'setglyphdata.log'}), + ] + +def doit(args): + logger = args.logger + gdcsv = args.glyphdata + addcsv = args.addcsv + dellist = args.deletions + sortheader = args.sortheader + force = args.force + + # Check arguments are valid + if not(addcsv or dellist or sortheader): logger.log("At least one of -a, -d or -s must be specified", "S") + if force and not addcsv: logger.log("-f should only be used with -a", "S") + + # + # Process the glyph_data.csv + # + + # Process the headers line + gdheaders = gdcsv.firstline + if 'glyph_name' not in gdheaders: logger.log("No glyph_name header in glyph data csv", "S") + gdcsv.numfields = len(gdheaders) + gdheaders = {header: col for col, header in enumerate(gdheaders)} # Turn into dict of form header: column + gdnamecol = gdheaders["glyph_name"] + if sortheader and sortheader not in gdheaders: + logger.log(sortheader + " not in glyph data headers", "S") + next(gdcsv.reader, None) # Skip first line with headers in + + # Read the data in + logger.log("Reading in existing glyph data file", "P") + gddata = {} + gdorder = [] + for line in gdcsv: + gname = line[gdnamecol] + gddata[gname] = line + gdorder.append(gname) + + # Delete records from dellist + + if dellist: + logger.log("Deleting items from glyph data based on deletions file", "P") + dellist.numfields = 1 + for line in dellist: + gname = line[0] + if gname in gdorder: + del gddata[gname] + gdorder.remove(gname) + logger.log(gname + " deleted from glyph data", "I") + else: + logger.log(gname + "not in glyph data", "W") + + # + # Process the addcsv, if present + # + + if addcsv: + # Check if addcsv has headers; if not use gdheaders + addheaders = addcsv.firstline + headerssame = True + if 'glyph_name' in addheaders: + if addheaders != gdcsv.firstline: headerssame = False + next(addcsv.reader) + else: + addheaders = gdheaders + + addcsv.numfields = len(addheaders) + addheaders = {header: col for col, header in enumerate(addheaders)} # Turn into dict of form header: column + addnamecol = addheaders["glyph_name"] + + logger.log("Adding new records from add csv file", "P") + for line in addcsv: + gname = line[addnamecol] + logtype = "added to" + if gname in gdorder: + if force: # Remove existing line + logtype = "replaced in" + del gddata[gname] + gdorder.remove(gname) + else: + logger.log(gname + " already in glyphdata so new data not added", "W") + continue + logger.log(f'{gname} {logtype} glyphdata', "I") + + if not headerssame: # need to construct new line based on addheaders + newline = [] + for header in gdheaders: + val = line[addheaders[header]] if header in addheaders else "" + newline.append(val) + line = newline + + gddata[gname] = line + gdorder.append(gname) + + # Finally sort the data if sortheader supplied + def numeric(x): + try: + numx = float(x) + except ValueError: + logger.log(f'Non-numeric value "{x}" in sort column; 0 used for sorting', "E") + numx = 0 + return numx + + if sortheader: + sortheaderpos = gdheaders[sortheader] + if args.sortalpha: + gdorder = sorted(gdorder, key=lambda x: gddata[x][sortheaderpos]) + else: + gdorder = sorted(gdorder, key=lambda x: numeric(gddata[x][sortheaderpos])) + + # Now write the data out + outfile = args.outglyphdata + if not outfile: + gdcsv.file.close() + outfile = gdcsv.filename + logger.log(f'Writing glyph data out to {outfile}', "P") + with open(outfile, "w", newline="") as f: + writer = csv.writer(f) + writer.writerow(gdcsv.firstline) + for glyphn in gdorder: + writer.writerow(gddata[glyphn]) + +def cmd() : execute("",doit,argspec) +if __name__ == "__main__": cmd() + diff --git a/src/silfont/scripts/psfsetglyphorder.py b/src/silfont/scripts/psfsetglyphorder.py new file mode 100644 index 0000000..f05889c --- /dev/null +++ b/src/silfont/scripts/psfsetglyphorder.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +__doc__ = '''Load glyph order data into public.glyphOrder in lib.plist based on based on a text file in one of two formats: + - simple text file with one glyph name per line + - csv file with headers, using headers "glyph_name" and "sort_final" where the latter contains + numeric values used to sort the glyph names by''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute +from xml.etree import ElementTree as ET + +argspec = [ + ('ifont', {'help': 'Input font file'}, {'type': 'infont'}), + ('ofont', {'help': 'Output font file', 'nargs': '?'}, {'type': 'outfont'}), + ('--gname', {'help': 'Column header for glyph name', 'default': 'glyph_name'}, {}), + ('--header', {'help': 'Column header(s) for sort order', 'default': 'sort_final'}, {}), + ('--field', {'help': 'Field(s) in lib.plist to update', 'default': 'public.glyphOrder'}, {}), + ('-i', '--input', {'help': 'Input text file, one glyphname per line'}, {'type': 'incsv', 'def': 'glyph_data.csv'}), + ('-x', '--removemissing', {'help': 'Remove from list if glyph not in font', 'action': 'store_true', 'default': False}, {}), + ('-l', '--log', {'help': 'Log file'}, {'type': 'outfile', 'def': '_gorder.log'})] + + +def doit(args): + font = args.ifont + incsv = args.input + logger = args.logger + removemissing = args.removemissing + + fields = args.field.split(",") + fieldcount = len(fields) + headers = args.header.split(",") + if fieldcount != len(headers): logger.log("Must specify same number of values in --field and --header", "S") + gname = args.gname + + # Identify file format from first line then create glyphdata[] with glyph name then one column per header + glyphdata = {} + fl = incsv.firstline + if fl is None: logger.log("Empty input file", "S") + numfields = len(fl) + incsv.numfields = numfields + fieldpos = [] + if numfields > 1: # More than 1 column, so must have headers + if gname in fl: + glyphnpos = fl.index(gname) + else: + logger.log("No" + gname + "field in csv headers", "S") + for header in headers: + if header in fl: + pos = fl.index(header) + fieldpos.append(pos) + else: + logger.log('No "' + header + '" heading in csv headers"', "S") + next(incsv.reader, None) # Skip first line with headers in + for line in incsv: + glyphn = line[glyphnpos] + if len(glyphn) == 0: + continue # No need to include cases where name is blank + glyphdata[glyphn]=[] + for pos in fieldpos: glyphdata[glyphn].append(float(line[pos])) + elif numfields == 1: # Simple text file. Create glyphdata in same format as for csv files + for i, line in enumerate(incsv): glyphdata[line[0]]=(i,) + else: + logger.log("Invalid csv file", "S") + + # Now process the data + if "lib" not in font.__dict__: font.addfile("lib") + glyphlist = list(font.deflayer.keys()) + + for i in range(0,fieldcount): + array = ET.Element("array") + for glyphn, vals in sorted(glyphdata.items(), key=lambda item: item[1][i]): + if glyphn in glyphlist: + sub = ET.SubElement(array, "string") + sub.text = glyphn + else: + font.logger.log("No glyph in font for " + glyphn, "I") + if not removemissing: + sub = ET.SubElement(array, "string") + sub.text = glyphn + font.lib.setelem(fields[i-1],array) + + for glyphn in sorted(glyphlist): # Remaining glyphs were not in the input file + if glyphn not in glyphdata: font.logger.log("No entry in input file for font glyph " + glyphn, "I") + + return font + + +def cmd(): execute("UFO", doit, argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfsetkeys.py b/src/silfont/scripts/psfsetkeys.py new file mode 100644 index 0000000..7917de6 --- /dev/null +++ b/src/silfont/scripts/psfsetkeys.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +__doc__ = '''Set keys with given values in a UFO plist file.''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Bobby de Vos' + +from silfont.core import execute +from xml.etree import ElementTree as ET +import codecs + +suffix = "_setkeys" +argspec = [ + ('ifont',{'help': 'Input font file'}, {'type': 'infont'}), + ('ofont',{'help': 'Output font file','nargs': '?' }, {'type': 'outfont'}), + ('--plist',{'help': 'Select plist to modify'}, {'def': 'fontinfo'}), + ('-i','--input',{'help': 'Input csv file'}, {'type': 'incsv', 'def': None}), + ('-k','--key',{'help': 'Name of key to set'},{}), + ('-v','--value',{'help': 'Value to set key to'},{}), + ('--file',{'help': 'Use contents of file to set key to'},{}), + ('--filepart',{'help': 'Use contents of part of the file to set key to'},{}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': suffix+'.log'}) + ] + +def doit(args) : + + font = args.ifont + logger = args.logger + plist = args.plist + if plist is None: plist = "fontinfo" + if plist not in ("lib", "fontinfo"): + logger.log("--plist must be either fontinfo or lib", "S") + else: + if plist not in font.__dict__: font.addfile(plist) + logger.log("Adding keys to " + plist, "I") + font_plist = getattr(font, plist) + + # Ensure enough options were specified + value = args.value or args.file or args.filepart + if args.key and not value: + logger.log('Value needs to be specified', "S") + if not args.key and value: + logger.log('Key needs to be specified', "S") + + # Use a one line string to set the key + if args.key and args.value: + set_key_value(font_plist, args.key, args.value) + + # Use entire file contents to set the key + if args.key and args.file: + fh = codecs.open(args.file, 'r', 'utf-8') + contents = ''.join(fh.readlines()) + set_key_value(font_plist, args.key, contents) + fh.close() + + # Use some of the file contents to set the key + if args.key and args.filepart: + fh = codecs.open(args.filepart, 'r', 'utf-8') + lines = list() + for line in fh: + if line == '\n': + break + lines.append(line) + contents = ''.join(lines) + set_key_value(font_plist, args.key, contents) + fh.close() + + # Set many keys + if args.input: + incsv = args.input + incsv.numfields = 2 + + for line in incsv: + key = line[0] + value = line[1] + set_key_value(font_plist, key, value) + + return font + +def set_key_value(font_plist, key, value): + """Set key to value in font.""" + + # Currently setval() only works for integer, real or string. + # For other items you need to construct an elementtree element and use setelem() + + if value == 'true' or value == 'false': + # Handle boolean values + font_plist.setelem(key, ET.Element(value)) + else: + try: + # Handle integers values + number = int(value) + font_plist.setval(key, 'integer', number) + except ValueError: + # Handle string (including multi-line strings) values + font_plist.setval(key, 'string', value) + font_plist.font.logger.log(key + " added, value: " + str(value), "I") + +def cmd() : execute("UFO",doit, argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfsetmarkcolors.py b/src/silfont/scripts/psfsetmarkcolors.py new file mode 100644 index 0000000..91448a6 --- /dev/null +++ b/src/silfont/scripts/psfsetmarkcolors.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +__doc__ = ''' Sets the cell mark color of glyphs in a UFO +- Input file is a list of glyph names (or unicode values if -u is specified +- Color can be numeric or certain names, eg "0.85,0.26,0.06,1" or "g_red" +''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute, splitfn +from silfont.util import parsecolors +import io + +argspec = [ + ('ifont',{'help': 'Input font file'}, {'type': 'infont'}), + ('ofont',{'help': 'Output font file','nargs': '?' }, {'type': 'outfont'}), + ('-i','--input',{'help': 'input file'}, {'type': 'filename', 'def': 'nodefault.txt'}), + ('-c','--color',{'help': 'Color to set'},{}), + ('-u','--unicodes',{'help': 'Use unicode values in input file', 'action': 'store_true', 'default': False},{}), + ('-x','--deletecolors',{'help': 'Delete existing mark colors', 'action': 'store_true', 'default': False},{}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': '_setmarkcolors.log'})] + +def doit(args) : + font = args.ifont + logger = args.logger + infile = args.input + color = args.color + unicodes = args.unicodes + deletecolors = args.deletecolors + + if not ((color is not None) ^ deletecolors): logger.log("Must specify one and only one of -c and -x", "S") + + if color is not None: + (color, colorname, logcolor, splitcolor) = parsecolors(color, single=True) + if color is None: logger.log(logcolor, "S") # If color not parsed, parsecolors() puts error in logcolor + + # Process the input file. It needs to be done in script rather than by execute() since, if -x is used, there might not be one + (ibase, iname, iext) = splitfn(infile) + if iname == "nodefault": # Indicates no file was specified + infile = None + if (color is not None) or unicodes or (not deletecolors): logger.log("If no input file, -x must be used and neither -c or -u can be used", "S") + else: + logger.log('Opening file for input: ' + infile, "P") + try: + infile = io.open(infile, "r", encoding="utf-8") + except Exception as e: + logger.log("Failed to open file: " + str(e), "S") + + # Create list of glyphs to process + if deletecolors and infile is None: # Need to delete colors from all glyphs + glyphlist = sorted(font.deflayer.keys()) + else: + inlist = [x.strip() for x in infile.readlines()] + glyphlist = [] + if unicodes: + unicodesfound = [] + for glyphn in sorted(font.deflayer.keys()): + glyph = font.deflayer[glyphn] + for unicode in [x.hex for x in glyph["unicode"]]: + if unicode in inlist: + glyphlist.append(glyphn) + unicodesfound.append(unicode) + for unicode in inlist: + if unicode not in unicodesfound: logger.log("No gylphs with unicode '" + unicode + "' in the font", "I") + else: + for glyphn in inlist: + if glyphn in font.deflayer: + glyphlist.append(glyphn) + else: + logger.log(glyphn + " is not in the font", "I") + + changecnt = 0 + for glyphn in glyphlist: + glyph = font.deflayer[glyphn] + oldcolor = None + lib = glyph["lib"] + if lib: + if "public.markColor" in lib: oldcolor = str(glyph["lib"].getval("public.markColor")) + if oldcolor != color: + if oldcolor is not None: + (temp, oldname, oldlogcolor, splitcolor) = parsecolors(oldcolor, single=True) + if temp is None: oldlogcolor = oldcolor # Failed to parse old color, so just report what is was + + changecnt += 1 + if deletecolors: + glyph["lib"].remove("public.markColor") + logger.log(glyphn + ": " + oldlogcolor + " removed", "I") + else: + if oldcolor is None: + if lib is None: glyph.add("lib") + glyph["lib"].setval("public.markColor","string",color) + logger.log(glyphn+ ": " + logcolor + " added", "I") + else: + glyph["lib"].setval("public.markColor", "string", color) + logger.log(glyphn + ": " + oldlogcolor + " changed to " + logcolor, "I") + + if deletecolors: + logger.log(str(changecnt) + " colors removed", "P") + else: + logger.log(str(changecnt) + " colors changed or added", "P") + + return font + +def cmd() : execute("UFO",doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfsetpsnames.py b/src/silfont/scripts/psfsetpsnames.py new file mode 100644 index 0000000..f95a1a7 --- /dev/null +++ b/src/silfont/scripts/psfsetpsnames.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +__doc__ = '''Add public.postscriptNames to lib.plist based on a csv file in one of two formats: + - simple glyphname, postscriptname with no headers + - with headers, where the headers for glyph name and postscript name "glyph_name" and "ps_name"''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute +from xml.etree import ElementTree as ET + +argspec = [ + ('ifont', {'help': 'Input font file'}, {'type': 'infont'}), + ('ofont', {'help': 'Output font file', 'nargs': '?'}, {'type': 'outfont'}), + ('--gname', {'help': 'Column header for glyph name', 'default': 'glyph_name'}, {}), + ('-i', '--input', {'help': 'Input csv file'}, {'type': 'incsv', 'def': 'glyph_data.csv'}), + ('-x', '--removemissing', {'help': 'Remove from list if glyph not in font', 'action': 'store_true', 'default': False}, {}), + ('-l', '--log', {'help': 'Log file'}, {'type': 'outfile', 'def': 'setpsnames.log'})] + + +def doit(args): + font = args.ifont + logger = args.logger + incsv = args.input + gname = args.gname + removemissing = args.removemissing + + glyphlist = list(font.deflayer.keys()) # List to check every glyph has a psname supplied + + # Identify file format from first line + fl = incsv.firstline + if fl is None: logger.log("Empty input file", "S") + numfields = len(fl) + incsv.numfields = numfields + if numfields == 2: + glyphnpos = 0 + psnamepos = 1 # Default for plain csv + elif numfields > 2: # More than 2 columns, so must have standard headers + if gname in fl: + glyphnpos = fl.index(gname) + else: + logger.log("No " + gname + " field in csv headers", "S") + if "ps_name" in fl: + psnamepos = fl.index("ps_name") + else: + logger.log("No ps_name field in csv headers", "S") + next(incsv.reader, None) # Skip first line with headers in + else: + logger.log("Invalid csv file", "S") + + # Now process the data + dict = ET.Element("dict") + for line in incsv: + glyphn = line[glyphnpos] + psname = line[psnamepos] + if len(psname) == 0 or glyphn == psname: + continue # No need to include cases where production name is blank or same as working name + # Check if in font + infont = False + if glyphn in glyphlist: + glyphlist.remove(glyphn) + infont = True + else: + if not removemissing: logger.log("No glyph in font for " + glyphn + " on line " + str(incsv.line_num), "I") + if not removemissing or infont: + # Add to dict + sub = ET.SubElement(dict, "key") + sub.text = glyphn + sub = ET.SubElement(dict, "string") + sub.text = psname + # Add to lib.plist + if len(dict) > 0: + if "lib" not in font.__dict__: font.addfile("lib") + font.lib.setelem("public.postscriptNames", dict) + else: + if "lib" in font.__dict__ and "public.postscriptNames" in font.lib: + font.lib.remove("public.postscriptNames") + + for glyphn in sorted(glyphlist): logger.log("No PS name in input file for font glyph " + glyphn, "I") + + return font + + +def cmd(): execute("UFO", doit, argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfsetunicodes.py b/src/silfont/scripts/psfsetunicodes.py new file mode 100644 index 0000000..461207b --- /dev/null +++ b/src/silfont/scripts/psfsetunicodes.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +__doc__ = '''Set the unicodes of glyphs in a font based on an external csv file. +- csv format glyphname,unicode, [unicode2, [,unicode3]]''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2016 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Victor Gaultney, based on UFOsetPSnames.py' + +from silfont.core import execute + +suffix = "_setunicodes" +argspec = [ + ('ifont',{'help': 'Input font file'}, {'type': 'infont'}), + ('ofont',{'help': 'Output font file','nargs': '?' }, {'type': 'outfont'}), + ('-i','--input',{'help': 'Input csv file'}, {'type': 'incsv', 'def': suffix+'.csv'}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': suffix+'.log'})] + +def doit(args) : + font = args.ifont + incsv = args.input + logger = args.logger + # Allow for up to 3 unicode values per glyph + incsv.minfields = 2 + incsv.maxfields = 4 + + # List of glyphnames actually in the font: + glyphlist = list(font.deflayer.keys()) + + # Create mapping to find glyph name from decimal usv: + dusv2gname = {int(unicode.hex, 16): gname for gname in glyphlist for unicode in font.deflayer[gname]['unicode']} + + # Remember what glyphnames we've processed: + processed = set() + + for line in incsv : + glyphn = line[0] + # Allow for up to 3 unicode values + dusvs = [] + for col in range(1,len(line)): + try: + dusv = int(line[col],16) # sanity check and convert to decimal + except ValueError: + logger.log("Invalid USV '%s'; line %d ignored." % (line[col], incsv.line_num), "W") + continue + dusvs.append(dusv) + + if glyphn in glyphlist : + + if glyphn in processed: + logger.log(f"Glyph {glyphn} in csv more than once; line {incsv.line_num} ignored.", "W") + + glyph = font.deflayer[glyphn] + # Remove existing unicodes + for unicode in list(glyph["unicode"]): + del dusv2gname[int(unicode.hex, 16)] + glyph.remove("unicode",index = 0) + + # Add the new unicode(s) in + for dusv in dusvs: + # See if any glyph already encodes this unicode value: + if dusv in dusv2gname: + # Remove this encoding from the other glyph: + oglyph = font.deflayer[dusv2gname[dusv]] + for unicode in oglyph["unicode"]: + if int(unicode.hex,16) == dusv: + oglyph.remove("unicode", object=unicode) + break + # Add this unicode value and update dusv2gname + dusv2gname[dusv] = glyphn + glyph.add("unicode",{"hex": ("%04X" % dusv)}) # Standardize to 4 (or more) digits and caps + # Record that we processed this glyphname, + processed.add(glyphn) + else : + logger.log("Glyph '%s' not in font; line %d ignored." % (glyphn, incsv.line_num), "I") + + return font + +def cmd() : execute("UFO",doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfsetversion.py b/src/silfont/scripts/psfsetversion.py new file mode 100644 index 0000000..7e8fc91 --- /dev/null +++ b/src/silfont/scripts/psfsetversion.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +__doc__ = '''Update the various font version fields''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute +import silfont.ufo as UFO +import re + +argspec = [ + ('font',{'help': 'From font file'}, {'type': 'infont'}), + ('newversion',{'help': 'Version string or increment', 'nargs': '?'}, {}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': '_setversion.log'}) + ] + +otnvre = re.compile('Version (\d)\.(\d\d\d)( .+)?$') + +def doit(args) : + + font = args.font + logger = args.logger + newversion = args.newversion + + + fi = font.fontinfo + otelem = fi["openTypeNameVersion"][1] if "openTypeNameVersion" in fi else None + majelem = fi["versionMajor"][1] if "versionMajor" in fi else None + minelem = fi["versionMinor"][1] if "versionMinor" in fi else None + otnv = None if otelem is None else otelem.text + vmaj = None if majelem is None else majelem.text + vmin = None if minelem is None else minelem.text + + if otnv is None or vmaj is None or vmin is None : logger.log("At least one of openTypeNameVersion, versionMajor or versionMinor missing from fontinfo.plist", "S") + + if newversion is None: + if otnvre.match(otnv) is None: + logger.log("Current version is '" + otnv + "' which is non-standard", "E") + else : + logger.log("Current version is '" + otnv + "'", "P") + (otmaj,otmin,otextrainfo) = parseotnv(otnv) + if (otmaj, int(otmin)) != (vmaj,int(vmin)) : + logger.log("openTypeNameVersion values don't match versionMajor (" + vmaj + ") and versionMinor (" + vmin + ")", "E") + else: + if newversion[0:1] == "+" : + if otnvre.match(otnv) is None: + logger.log("Current openTypeNameVersion is non-standard so can't be incremented: " + otnv , "S") + else : + (otmaj,otmin,otextrainfo) = parseotnv(otnv) + if (otmaj, int(otmin)) != (vmaj,int(vmin)) : + logger.log("openTypeNameVersion (" + otnv + ") doesn't match versionMajor (" + vmaj + ") and versionMinor (" + vmin + ")", "S") + # Process increment to versionMinor. Note vmin is treated as 3 digit mpp where m and pp are minor and patch versions respectively + increment = newversion[1:] + if increment not in ("1", "0.001", ".001", "0.1", ".1") : + logger.log("Invalid increment value - must be one of 1, 0.001, .001, 0.1 or .1", "S") + increment = 100 if increment in ("0.1", ".1") else 1 + if (increment == 100 and vmin[0:1] == "9") or (increment == 1 and vmin[1:2] == "99") : + logger.log("Version already at maximum so can't be incremented", "S") + otmin = str(int(otmin) + increment).zfill(3) + else : + newversion = "Version " + newversion + if otnvre.match(newversion) is None: + logger.log("newversion format invalid - should be 'M.mpp' or 'M.mpp extrainfo'", "S") + else : + (otmaj,otmin,otextrainfo) = parseotnv(newversion) + newotnv = "Version " + otmaj + "." + otmin + otextrainfo # Extrainfo already as leading space + logger.log("Updating version from '" + otnv + "' to '" + newotnv + "'","P") + + # Update and write to disk + otelem.text = newotnv + majelem.text = otmaj + minelem.text = otmin + UFO.writeXMLobject(fi,font.outparams,font.ufodir, "fontinfo.plist" , True, fobject = True) + + return + +def parseotnv(string) : # Returns maj, min and extrainfo + m = otnvre.match(string) # Assumes string has already been tested for a match + extrainfo = "" if m.group(3) is None else m.group(3) + return (m.group(1), m.group(2), extrainfo) + + +def cmd() : execute("UFO",doit, argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfshownames.py b/src/silfont/scripts/psfshownames.py new file mode 100644 index 0000000..94e8d53 --- /dev/null +++ b/src/silfont/scripts/psfshownames.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +__doc__ = 'Display name fields and other bits for linking fonts into families' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2021 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Bobby de Vos' + +from silfont.core import execute, splitfn +from fontTools.ttLib import TTFont +import glob +from operator import attrgetter, methodcaller +import tabulate + +WINDOWS_ENGLISH_IDS = 3, 1, 0x409 + +FAMILY_RELATED_IDS = { + 1: 'Family', + 2: 'Subfamily', + 4: 'Full name', + 6: 'PostScript name', + 16: 'Typographic/Preferred family', + 17: 'Typographic/Preferred subfamily', + 21: 'WWS family', + 22: 'WWS subfamily', + 25: 'Variations PostScript Name Prefix', +} + + +class FontInfo: + def __init__(self): + self.filename = '' + self.name_table = dict() + self.weight_class = 0 + self.regular = '' + self.bold = '' + self.italic = '' + self.width = '' + self.width_name = '' + self.width_class = 0 + self.wws = '' + + def sort_fullname(self): + return self.name_table[4] + + +argspec = [ + ('font', {'help': 'ttf font(s) to run report against; wildcards allowed', 'nargs': "+"}, {'type': 'filename'}), + ('-b', '--bits', {'help': 'Show bits', 'action': 'store_true'}, {}), + ('-m', '--multiline', {'help': 'Output multi-line key:values instead of a table', 'action': 'store_true'}, {}), +] + + +def doit(args): + logger = args.logger + + font_infos = [] + for pattern in args.font: + for fullpath in glob.glob(pattern): + logger.log(f'Processing {fullpath}', 'P') + try: + font = TTFont(fullpath) + except Exception as e: + logger.log(f'Error opening {fullpath}: {e}', 'E') + break + + font_info = FontInfo() + font_info.filename = fullpath + get_names(font, font_info) + get_bits(font, font_info) + font_infos.append(font_info) + + if not font_infos: + logger.log("No files match the filespec provided for fonts: " + str(args.font), "S") + + font_infos.sort(key=methodcaller('sort_fullname')) + font_infos.sort(key=attrgetter('width_class'), reverse=True) + font_infos.sort(key=attrgetter('weight_class')) + + rows = list() + if args.multiline: + # Multi-line mode + for font_info in font_infos: + for line in multiline_names(font_info): + rows.append(line) + if args.bits: + for line in multiline_bits(font_info): + rows.append(line) + align = ['left', 'right'] + if len(font_infos) == 1: + del align[0] + for row in rows: + del row[0] + output = tabulate.tabulate(rows, tablefmt='plain', colalign=align) + output = output.replace(': ', ':') + output = output.replace('#', '') + else: + # Table mode + + # Record information for headers + headers = table_headers(args.bits) + + # Record information for each instance. + for font_info in font_infos: + record = table_records(font_info, args.bits) + rows.append(record) + + # Not all fonts in a family with have the same name ids present, + # for instance 16: Typographic/Preferred family is only needed in + # non-RIBBI families, and even then only for the non-RIBBI instances. + # Also, not all the bit fields are present in each instance. + # Therefore, columns with no data in any instance are removed. + indices = list(range(len(headers))) + indices.reverse() + for index in indices: + empty = True + for row in rows: + data = row[index] + if data: + empty = False + if empty: + for row in rows + [headers]: + del row[index] + + # Format 'pipe' is nicer for GitHub, but is wider on a command line + output = tabulate.tabulate(rows, headers, tablefmt='simple') + + # Print output from either mode + if args.quiet: + print(output) + else: + logger.log('The following family-related values were found in the name, head, and OS/2 tables\n' + output, 'P') + + +def get_names(font, font_info): + table = font['name'] + (platform_id, encoding_id, language_id) = WINDOWS_ENGLISH_IDS + + for name_id in FAMILY_RELATED_IDS: + record = table.getName( + nameID=name_id, + platformID=platform_id, + platEncID=encoding_id, + langID=language_id + ) + if record: + font_info.name_table[name_id] = str(record) + + +def get_bits(font, font_info): + os2 = font['OS/2'] + head = font['head'] + font_info.weight_class = os2.usWeightClass + font_info.regular = bit2code(os2.fsSelection, 6, 'W-') + font_info.bold = bit2code(os2.fsSelection, 5, 'W') + font_info.bold += bit2code(head.macStyle, 0, 'M') + font_info.italic = bit2code(os2.fsSelection, 0, 'W') + font_info.italic += bit2code(head.macStyle, 1, 'M') + font_info.width_class = os2.usWidthClass + font_info.width = str(font_info.width_class) + if font_info.width_class == 5: + font_info.width_name = 'Width-Normal' + if font_info.width_class < 5: + font_info.width_name = 'Width-Condensed' + font_info.width += bit2code(head.macStyle, 5, 'M') + if font_info.width_class > 5: + font_info.width_name = 'Width-Extended' + font_info.width += bit2code(head.macStyle, 6, 'M') + font_info.wws = bit2code(os2.fsSelection, 8, '8') + + +def bit2code(bit_field, bit, code_letter): + code = '' + if bit_field & 1 << bit: + code = code_letter + return code + + +def multiline_names(font_info): + for name_id in sorted(font_info.name_table): + line = [font_info.filename + ':', + str(name_id) + ':', + FAMILY_RELATED_IDS[name_id] + ':', + font_info.name_table[name_id] + ] + yield line + + +def multiline_bits(font_info): + labels = ('usWeightClass', 'Regular', 'Bold', 'Italic', font_info.width_name, 'WWS') + values = (font_info.weight_class, font_info.regular, font_info.bold, font_info.italic, font_info.width, font_info.wws) + for label, value in zip(labels, values): + if not value: + continue + line = [font_info.filename + ':', + '#', + str(label) + ':', + value + ] + yield line + + +def table_headers(bits): + headers = ['filename'] + for name_id in sorted(FAMILY_RELATED_IDS): + name_id_key = FAMILY_RELATED_IDS[name_id] + header = f'{name_id}: {name_id_key}' + if len(header) > 20: + header = header.replace(' ', '\n') + header = header.replace('/', '\n') + headers.append(header) + if bits: + headers.extend(['wght', 'R', 'B', 'I', 'wdth', 'WWS']) + return headers + + +def table_records(font_info, bits): + record = [font_info.filename] + for name_id in sorted(FAMILY_RELATED_IDS): + name_id_value = font_info.name_table.get(name_id, '') + record.append(name_id_value) + if bits: + record.append(font_info.weight_class) + record.append(font_info.regular) + record.append(font_info.bold) + record.append(font_info.italic) + record.append(font_info.width) + record.append(font_info.wws) + return record + + +def cmd(): execute('FT', doit, argspec) + + +if __name__ == '__main__': + cmd() diff --git a/src/silfont/scripts/psfsubset.py b/src/silfont/scripts/psfsubset.py new file mode 100644 index 0000000..da9c4e6 --- /dev/null +++ b/src/silfont/scripts/psfsubset.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +__doc__ = '''Subset an existing UFO based on a csv or text list of glyph names or USVs to keep. +''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018-2023 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Bob Hallissy' + +from silfont.core import execute +from xml.etree import ElementTree as ET +import re + +argspec = [ + ('ifont',{'help': 'Input font file'}, {'type': 'infont'}), + ('ofont',{'help': 'Output font file','nargs': '?' }, {'type': 'outfont'}), + ('-i','--input',{'help': 'Input csv file'}, {'type': 'incsv'}), + ('--header', {'help': 'Column header for glyph list', 'default': 'glyph_name'}, {}), + ('--filter', {'help': 'Column header for filter status', 'default': None}, {}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': '_subset.log'})] + +def doit(args) : + font = args.ifont + incsv = args.input + logger = args.logger + deflayer = font.deflayer + + # Create mappings to find glyph name from decimal usv: + dusv2gname = {int(ucode.hex, 16): gname for gname in deflayer for ucode in deflayer[gname]['unicode']} + + # check for headers in the csv + fl = incsv.firstline + if fl is None: logger.log("Empty input file", "S") + numfields = len(fl) + if numfields == 1 and args.header not in fl: + dataCol = 0 # Default for plain csv + elif numfields >= 1: # Must have headers + try: + dataCol = fl.index(args.header) + except ValueError as e: + logger.log(f'Missing csv header field: {e}', 'S') + except Exception as e: + logger.log(f'Error reading csv header field: {e}', 'S') + if args.filter: + try: + filterCol = fl.index(args.filter) + except ValueError as e: + logger.log(f'Missing csv filter field: {e}', 'S') + except Exception as e: + logger.log(f'Error reading csv filter field: {e}', 'S') + next(incsv.reader, None) # Skip first line with headers in + else: + logger.log("Invalid csv file", "S") + + # From the csv, assemble a list of glyphs to process: + toProcess = set() + usvRE = re.compile('[0-9a-f]{4,6}$',re.IGNORECASE) # matches 4-6 digit hex + for r in incsv: + if args.filter: + filterstatus = r[filterCol].strip() + if filterstatus != "Y": + continue + gname = r[dataCol].strip() + if usvRE.match(gname): + # data is USV, not glyph name + dusv = int(gname,16) + if dusv in dusv2gname: + toProcess.add(dusv2gname[dusv]) + continue + # The USV wasn't in the font... try it as a glyph name + if gname not in deflayer: + logger.log("Glyph '%s' not in font; line %d ignored" % (gname, incsv.line_num), 'W') + continue + toProcess.add(gname) + + # Generate a complete list of glyphs to keep: + toKeep = set() + while len(toProcess): + gname = toProcess.pop() # retrieves a random item from the set + if gname in toKeep: + continue # Already processed this one + toKeep.add(gname) + + # If it has any components we haven't already processed, add them to the toProcess list + for component in deflayer[gname].etree.findall('./outline/component[@base]'): + cname = component.get('base') + if cname not in toKeep: + toProcess.add(cname) + + # Generate a complete list of glyphs to delete: + toDelete = set(deflayer).difference(toKeep) + + # Remove any glyphs not in the toKeep set + for gname in toDelete: + logger.log("Deleting " + gname, "V") + deflayer.delGlyph(gname) + assert len(deflayer) == len(toKeep), "len(deflayer) != len(toKeep)" + logger.log("Retained %d glyphs, deleted %d glyphs." % (len(toKeep), len(toDelete)), "P") + + # Clean up and rebuild sort orders + libexists = True if "lib" in font.__dict__ else False + for orderName in ('public.glyphOrder', 'com.schriftgestaltung.glyphOrder'): + if libexists and orderName in font.lib: + glyphOrder = font.lib.getval(orderName) # This is an array + array = ET.Element("array") + for gname in glyphOrder: + if gname in toKeep: + ET.SubElement(array, "string").text = gname + font.lib.setelem(orderName, array) + + # Clean up and rebuild psnames + if libexists and 'public.postscriptNames' in font.lib: + psnames = font.lib.getval('public.postscriptNames') # This is a dict keyed by glyphnames + dict = ET.Element("dict") + for gname in psnames: + if gname in toKeep: + ET.SubElement(dict, "key").text = gname + ET.SubElement(dict, "string").text = psnames[gname] + font.lib.setelem("public.postscriptNames", dict) + + return font + +def cmd() : execute("UFO",doit,argspec) + +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfsyncmasters.py b/src/silfont/scripts/psfsyncmasters.py new file mode 100644 index 0000000..fb23637 --- /dev/null +++ b/src/silfont/scripts/psfsyncmasters.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +__doc__ = '''Sync metadata across a family of fonts based on designspace files''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute +import silfont.ufo as UFO +import silfont.etutil as ETU +import os, datetime +import fontTools.designspaceLib as DSD +from xml.etree import ElementTree as ET + +argspec = [ + ('primaryds', {'help': 'Primary design space file'}, {'type': 'filename'}), + ('secondds', {'help': 'Second design space file', 'nargs': '?', 'default': None}, {'type': 'filename', 'def': None}), + ('--complex', {'help': 'Obsolete - here for backwards compatibility only', 'action': 'store_true', 'default': False},{}), + ('-l','--log', {'help': 'Log file'}, {'type': 'outfile', 'def': '_sync.log'}), + ('-n','--new', {'help': 'append "_new" to file names', 'action': 'store_true', 'default': False},{}) # For testing/debugging + ] + +def doit(args) : + ficopyreq = ("ascender", "copyright", "descender", "familyName", "openTypeHheaAscender", + "openTypeHheaDescender", "openTypeHheaLineGap", "openTypeNameDescription", "openTypeNameDesigner", + "openTypeNameDesignerURL", "openTypeNameLicense", "openTypeNameLicenseURL", + "openTypeNameManufacturer", "openTypeNameManufacturerURL", "openTypeNamePreferredFamilyName", + "openTypeNameVersion", "openTypeOS2CodePageRanges", "openTypeOS2TypoAscender", + "openTypeOS2TypoDescender", "openTypeOS2TypoLineGap", "openTypeOS2UnicodeRanges", + "openTypeOS2VendorID", "openTypeOS2WinAscent", "openTypeOS2WinDescent", "versionMajor", + "versionMinor") + ficopyopt = ("openTypeNameSampleText", "postscriptFamilyBlues", "postscriptFamilyOtherBlues", "styleMapFamilyName", + "trademark", "woffMetadataCredits", "woffMetadataDescription") + fispecial = ("italicAngle", "openTypeOS2WeightClass", "openTypeNamePreferredSubfamilyName", "openTypeNameUniqueID", + "styleName", "unitsPerEm") + fiall = sorted(set(ficopyreq) | set(ficopyopt) | set(fispecial)) + firequired = ficopyreq + ("openTypeOS2WeightClass", "styleName", "unitsPerEm") + libcopyreq = ("com.schriftgestaltung.glyphOrder", "public.glyphOrder", "public.postscriptNames") + libcopyopt = ("public.skipExportGlyphs",) + liball = sorted(set(libcopyreq) | set(libcopyopt)) + logger = args.logger + + pds = DSD.DesignSpaceDocument() + pds.read(args.primaryds) + if args.secondds is not None: + sds = DSD.DesignSpaceDocument() + sds.read(args.secondds) + else: + sds = None + # Extract weight mappings from axes + pwmap = swmap = {} + for (ds, wmap, name) in ((pds, pwmap, "primary"),(sds, swmap, "secondary")): + if ds: + rawmap = None + for descriptor in ds.axes: + if descriptor.name == "weight": + rawmap = descriptor.map + break + if rawmap: + for (cssw, xvalue) in rawmap: + wmap[int(xvalue)] = int(cssw) + else: + logger.log(f"No weight axes mapping in {name} design space", "W") + + # Process all the sources + psource = None + dsources = [] + for source in pds.sources: + if source.copyInfo: + if psource: logger.log('Multiple fonts with <info copy="1" />', "S") + psource = Dsource(pds, source, logger, frompds=True, psource = True, args = args) + else: + dsources.append(Dsource(pds, source, logger, frompds=True, psource = False, args = args)) + if sds is not None: + for source in sds.sources: + dsources.append(Dsource(sds, source, logger, frompds=False, psource = False, args=args)) + + # Process values in psource + fipval = {} + libpval = {} + changes = False + reqmissing = False + + for field in fiall: + pval = psource.fontinfo.getval(field) if field in psource.fontinfo else None + oval = pval + # Set values or do other checks for special cases + if field == "italicAngle": + if "italic" in psource.source.filename.lower(): + if pval is None or pval == 0 : + logger.log(f"{psource.source.filename}: Italic angle must be non-zero for italic fonts", "E") + else: + if pval is not None and pval != 0 : + logger.log(f"{psource.source.filename}: Italic angle must be zero for non-italic fonts", "E") + pval = None + elif field == "openTypeOS2WeightClass": + desweight = int(psource.source.location["weight"]) + if desweight in pwmap: + pval = pwmap[desweight] + else: + logger.log(f"Design weight {desweight} not in axes mapping so openTypeOS2WeightClass not updated", "I") + elif field in ("styleName", "openTypeNamePreferredSubfamilyName"): + pval = psource.source.styleName + elif field == "openTypeNameUniqueID": + nm = str(fipval["openTypeNameManufacturer"]) # Need to wrap with str() just in case missing from + fn = str(fipval["familyName"]) # fontinfo so would have been set to None + sn = psource.source.styleName + pval = nm + ": " + fn + " " + sn + ": " + datetime.datetime.now().strftime("%Y") + elif field == "unitsperem": + if pval is None or pval <= 0: logger.log("unitsperem must be non-zero", "S") + # After processing special cases, all required fields should have values + if pval is None and field in firequired: + reqmissing = True + logger.log("Required fontinfo field " + field + " missing from " + psource.source.filename, "E") + elif oval != pval: + changes = True + if pval is None: + if field in psource.fontinfo: psource.fontinfo.remove(field) + else: + psource.fontinfo[field][1].text = str(pval) + logchange(logger, f"{psource.source.filename}: {field} updated:", oval, pval) + fipval[field] = pval + if reqmissing: logger.log("Required fontinfo fields missing from " + psource.source.filename, "S") + if changes: + psource.fontinfo.setval("openTypeHeadCreated", "string", + datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S")) + psource.write("fontinfo") + + for field in liball: + pval = psource.lib.getval(field) if field in psource.lib else None + if pval is None: + if field in libcopyreq: + logtype = "W" if field[0:7] == "public." else "I" + logger.log("lib.plist field " + field + " missing from " + psource.source.filename, logtype) + libpval[field] = pval + + # Now update values in other source fonts + + for dsource in dsources: + logger.log("Processing " + dsource.ufodir, "I") + fchanges = False + for field in fiall: + sval = dsource.fontinfo.getval(field) if field in dsource.fontinfo else None + oval = sval + pval = fipval[field] + # Set values or do other checks for special cases + if field == "italicAngle": + if "italic" in dsource.source.filename.lower(): + if sval is None or sval == 0: + logger.log(dsource.source.filename + ": Italic angle must be non-zero for italic fonts", "E") + else: + if sval is not None and sval != 0: + logger.log(dsource.source.filename + ": Italic angle must be zero for non-italic fonts", "E") + sval = None + elif field == "openTypeOS2WeightClass": + desweight = int(dsource.source.location["weight"]) + if desweight in swmap: + sval = swmap[desweight] + else: + logger.log(f"Design weight {desweight} not in axes mapping so openTypeOS2WeightClass not updated", "I") + elif field in ("styleName", "openTypeNamePreferredSubfamilyName"): + sval = dsource.source.styleName + elif field == "openTypeNameUniqueID": + sn = dsource.source.styleName + sval = nm + ": " + fn + " " + sn + ": " + datetime.datetime.now().strftime("%Y") + else: + sval = pval + if oval != sval: + if field == "unitsPerEm": logger.log("unitsPerEm inconsistent across fonts", "S") + fchanges = True + if sval is None: + dsource.fontinfo.remove(field) + logmess = " removed: " + else: + logmess = " added: " if oval is None else " updated: " + # Copy value from primary. This will add if missing. + dsource.fontinfo.setelem(field, ET.fromstring(ET.tostring(psource.fontinfo[field][1]))) + # For fields where it is not a copy from primary... + if field in ("italicAngle", "openTypeNamePreferredSubfamilyName", "openTypeNameUniqueID", + "openTypeOS2WeightClass", "styleName"): + dsource.fontinfo[field][1].text = str(sval) + + logchange(logger, dsource.source.filename + " " + field + logmess, oval, sval) + + lchanges = False + for field in liball: + oval = dsource.lib.getval(field) if field in dsource.lib else None + pval = libpval[field] + if oval != pval: + lchanges = True + if pval is None: + dsource.lib.remove(field) + logmess = " removed: " + else: + dsource.lib.setelem(field, ET.fromstring(ET.tostring(psource.lib[field][1]))) + logmess = " updated: " + logchange(logger, dsource.source.filename + " " + field + logmess, oval, pval) + + if lchanges: + dsource.write("lib") + fchanges = True # Force fontinfo to update so openTypeHeadCreated is set + if fchanges: + dsource.fontinfo.setval("openTypeHeadCreated", "string", + datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S")) + dsource.write("fontinfo") + + logger.log("psfsyncmasters completed", "P") + +class Dsource(object): + def __init__(self, ds, source, logger, frompds, psource, args): + self.ds = ds + self.source = source + self.logger = logger + self.frompds = frompds # Boolean to say if came from pds + self.newfile = "_new" if args.new else "" + self.ufodir = source.path + if not os.path.isdir(self.ufodir): logger.log(self.ufodir + " in designspace doc does not exist", "S") + try: + self.fontinfo = UFO.Uplist(font=None, dirn=self.ufodir, filen="fontinfo.plist") + except Exception as e: + logger.log("Unable to open fontinfo.plist in " + self.ufodir, "S") + try: + self.lib = UFO.Uplist(font=None, dirn=self.ufodir, filen="lib.plist") + except Exception as e: + if psource: + logger.log("Unable to open lib.plist in " + self.ufodir, "E") + self.lib = {} # Just need empty dict, so all vals will be set to None + else: + logger.log("Unable to open lib.plist in " + self.ufodir + "; creating empty one", "E") + self.lib = UFO.Uplist() + self.lib.logger=logger + self.lib.etree = ET.fromstring("<plist>\n<dict/>\n</plist>") + self.lib.populate_dict() + self.lib.dirn = self.ufodir + self.lib.filen = "lib.plist" + + # Process parameters with similar logic to that in ufo.py. primarily to create outparams for writeXMLobject + libparams = {} + params = args.paramsobj + if "org.sil.pysilfontparams" in self.lib: + elem = self.lib["org.sil.pysilfontparams"][1] + if elem.tag != "array": + logger.log("Invalid parameter XML lib.plist - org.sil.pysilfontparams must be an array", "S") + for param in elem: + parn = param.tag + if not (parn in params.paramclass) or params.paramclass[parn] not in ("outparams", "ufometadata"): + logger.log( + "lib.plist org.sil.pysilfontparams must only contain outparams or ufometadata values: " + parn + " invalid", + "S") + libparams[parn] = param.text + # Create font-specific parameter set (with updates from lib.plist) Prepend names with ufodir to ensure uniqueness if multiple fonts open + params.addset(self.ufodir + "lib", "lib.plist in " + self.ufodir, inputdict=libparams) + if "command line" in params.sets: + params.sets[self.ufodir + "lib"].updatewith("command line", log=False) # Command line parameters override lib.plist ones + copyset = "main" if "main" in params.sets else "default" + params.addset(self.ufodir, copyset=copyset) + params.sets[self.ufodir].updatewith(self.ufodir + "lib", sourcedesc="lib.plist") + self.paramset = params.sets[self.ufodir] + # Validate specific parameters + if sorted(self.paramset["glifElemOrder"]) != sorted(params.sets["default"]["glifElemOrder"]): + logger.log("Invalid values for glifElemOrder", "S") + # Create outparams based on values in paramset, building attriborders from separate attriborders.<type> parameters. + self.outparams = {"attribOrders": {}} + for parn in params.classes["outparams"]: + value = self.paramset[parn] + if parn[0:12] == 'attribOrders': + elemname = parn.split(".")[1] + self.outparams["attribOrders"][elemname] = ETU.makeAttribOrder(value) + else: + self.outparams[parn] = value + self.outparams["UFOversion"] = 9 # Dummy value since not currently needed + + def write(self, plistn): + filen = plistn + self.newfile + ".plist" + self.logger.log("Writing updated " + plistn + ".plist to " + filen, "P") + exists = True if os.path.isfile(os.path.join(self.ufodir, filen)) else False + plist = getattr(self, plistn) + UFO.writeXMLobject(plist, self.outparams, self.ufodir, filen, exists, fobject=True) + + +def logchange(logger, logmess, old, new): + oldstr = str(old) if len(str(old)) < 22 else str(old)[0:20] + "..." + newstr = str(new) if len(str(new)) < 22 else str(new)[0:20] + "..." + if old is None: + logmess = logmess + " New value: " + newstr + else: + if new is None: + logmess = logmess + " Old value: " + oldstr + else: + logmess = logmess + " Old value: " + oldstr + ", new value: " + newstr + logger.log(logmess, "W") + # Extra verbose logging + if len(str(old)) > 21 : + logger.log("Full old value: " + str(old), "V") + if len(str(new)) > 21 : + logger.log("Full new value: " + str(new), "V") + logger.log("Types: Old - " + str(type(old)) + ", New - " + str(type(new)), "V") + + +def cmd() : execute(None,doit, argspec) +if __name__ == "__main__": cmd() + + +''' *** Code notes *** + +Does not check precision for float, since no float values are currently processed + - see processnum in psfsyncmeta if needed later + +''' diff --git a/src/silfont/scripts/psfsyncmeta.py b/src/silfont/scripts/psfsyncmeta.py new file mode 100644 index 0000000..5366bc5 --- /dev/null +++ b/src/silfont/scripts/psfsyncmeta.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +__doc__ = '''Sync metadata across a family of fonts assuming standard UFO file naming''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute +from datetime import datetime +import silfont.ufo as UFO +import os +from xml.etree import ElementTree as ET + +argspec = [ + ('ifont',{'help': 'Input font file'}, {'type': 'infont'}), + ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': '_sync.log'}), + ('-s','--single', {'help': 'Sync single UFO against master', 'action': 'store_true', 'default': False},{}), + ('-m','--master', {'help': 'Master UFO to sync single UFO against', 'nargs': '?' },{'type': 'infont', 'def': None}), + ('-r','--reportonly', {'help': 'Report issues but no updating', 'action': 'store_true', 'default': False},{}), + ('-n','--new', {'help': 'append "_new" to file/ufo names', 'action': 'store_true', 'default': False},{}), + ('--normalize', {'help': 'output all the fonts to normalize them', 'action': 'store_true', 'default': False},{}), + ] + +def doit(args) : + standardstyles = ["Regular", "Italic", "Bold", "BoldItalic"] + finfoignore = ["openTypeHeadCreated", "openTypeOS2Panose", "postscriptBlueScale", "postscriptBlueShift", + "postscriptBlueValues", "postscriptOtherBlues", "postscriptStemSnapH", "postscriptStemSnapV", "postscriptForceBold"] + libfields = ["public.postscriptNames", "public.glyphOrder", "com.schriftgestaltung.glyphOrder"] + + font = args.ifont + logger = args.logger + singlefont = args.single + mfont = args.master + newfile = "_new" if args.new else "" + reportonly = args.reportonly + updatemessage = " to be updated: " if reportonly else " updated: " + params = args.paramsobj + precision = font.paramset["precision"] + + # Increase screen logging level to W unless specific level supplied on command-line + if not(args.quiet or "scrlevel" in params.sets["command line"]) : logger.scrlevel = "W" + + # Process UFO name + (path,base) = os.path.split(font.ufodir) + (base,ext) = os.path.splitext(base) + if '-' not in base : logger.log("Non-standard UFO name - must be <family>-<style>", "S") + (family,style) = base.split('-') + + styles = [style] + fonts = {} + fonts[style] = font + + # Process single and master settings + if singlefont : + if mfont : + mastertext = "Master" # Used in log messages + else : # Check against Regular font from same family + mfont = openfont(params, path, family, "Regular") + if mfont is None : logger.log("No regular font to check against - use -m to specify master font", "S") + mastertext = "Regular" + fonts["Regular"] =mfont + else : # Supplied font must be Regular + if mfont : logger.log("-m --master must only be used with -s --single", "S") + if style != "Regular" : logger.log("Must specify a Regular font unless -s is used", "S") + mastertext = "Regular" + mfont = font + + # Check for required fields in master font + mfinfo = mfont.fontinfo + if "familyName" in mfinfo : + spacedfamily = mfinfo["familyName"][1].text + else: + logger.log("No familyName field in " + mastertext, "S") + if "openTypeNameManufacturer" in mfinfo : + manufacturer = mfinfo["openTypeNameManufacturer"][1].text + else: + logger.log("No openTypeNameManufacturer field in " + mastertext, "S") + mlib = mfont.lib + + # Open the remaining fonts in the family + if not singlefont : + for style in standardstyles : + if not style in fonts : + fonts[style] = openfont(params, path, family, style) # Will return None if font does not exist + if fonts[style] is not None : styles.append(style) + + # Process fonts + psuniqueidlist = [] + fieldscopied = False + for style in styles : + font = fonts[style] + if font.UFOversion != "2" : logger.log("This script only works with UFO 2 format fonts","S") + + fontname = family + "-" + style + spacedstyle = "Bold Italic" if style == "BoldItalic" else style + spacedname = spacedfamily + " " + spacedstyle + logger.log("************ Processing " + fontname, "P") + + ital = True if "Italic" in style else False + bold = True if "Bold" in style else False + + # Process fontinfo.plist + finfo=font.fontinfo + fieldlist = list(set(finfo) | set(mfinfo)) # Need all fields from both to detect missing fields + fchanged = False + + for field in fieldlist: + action = None; issue = ""; newval = "" + if field in finfo : + elem = finfo[field][1] + tag = elem.tag + text = elem.text + if text is None : text = "" + if tag == "real" : text = processnum(text,precision) + # Field-specific actions + + if field not in finfo : + if field not in finfoignore : action = "Copyfield" + elif field == "italicAngle" : + if ital and text == "0" : + issue = "is zero" + action = "Warn" + if not ital and text != "0" : + issue = "is non-zero" + newval = 0 + action = "Update" + elif field == "openTypeNameUniqueID" : + newval = manufacturer + ": " + spacedname + ": " + datetime.now().strftime("%Y") + if text != newval : + issue = "Incorrect value" + action = "Update" + elif field == "openTypeOS2WeightClass" : + if bold and text != "700" : + issue = "is not 700" + newval = 700 + action = "Update" + if not bold and text != "400" : + issue = "is not 400" + newval = 400 + action = "Update" + elif field == "postscriptFontName" : + if text != fontname : + newval = fontname + issue = "Incorrect value" + action = "Update" + elif field == "postscriptFullName" : + if text != spacedname : + newval = spacedname + issue = "Incorrect value" + action = "Update" + elif field == "postscriptUniqueID" : + if text in psuniqueidlist : + issue = "has same value as another font: " + text + action = "Warn" + else : + psuniqueidlist.append(text) + elif field == "postscriptWeightName" : + newval = 'bold' if bold else 'regular' + if text != newval : + issue = "Incorrect value" + action = 'Update' + elif field == "styleMapStyleName" : + if text != spacedstyle.lower() : + newval = spacedstyle.lower() + issue = "Incorrect value" + action = "Update" + elif field in ("styleName", "openTypeNamePreferredSubfamilyName") : + if text != spacedstyle : + newval = spacedstyle + issue = "Incorrect value" + action = "Update" + elif field in finfoignore : + action = "Ignore" + # Warn for fields in this font but not master + elif field not in mfinfo : + issue = "is in " + spacedstyle + " but not in " + mastertext + action = "Warn" + # for all other fields, sync values from master + else : + melem = mfinfo[field][1] + mtag = melem.tag + mtext = melem.text + if mtext is None : mtext = "" + if mtag == 'real' : mtext = processnum(mtext,precision) + if tag in ("real", "integer", "string") : + if mtext != text : + issue = "does not match " + mastertext + " value" + newval = mtext + action = "Update" + elif tag in ("true, false") : + if tag != mtag : + issue = "does not match " + mastertext + " value" + action = "FlipBoolean" + elif tag == "array" : # Assume simple array with just values to compare + marray = mfinfo.getval(field) + array = finfo.getval(field) + if array != marray: action = "CopyArray" + else : logger.log("Non-standard fontinfo field type in " + fontname, "X") + + # Now process the actions, create log messages etc + if action is None or action == "Ignore" : + pass + elif action == "Warn" : + logger.log(field + " needs manual correction: " + issue, "W") + elif action == "Error" : + logger.log(field + " needs manual correction: " + issue, "E") + elif action in ("Update", "FlipBoolean", "Copyfield", "CopyArray") : # Updating actions + fchanged = True + message = field + updatemessage + if action == "Update" : + message = message + issue + " Old: '" + text + "' New: '" + str(newval) + "'" + elem.text = newval + elif action == "FlipBoolean" : + newval = "true" if tag == "false" else "false" + message = message + issue + " Old: '" + tag + "' New: '" + newval + "'" + finfo.setelem(field, ET.fromstring("<" + newval + "/>")) + elif action == "Copyfield" : + message = message + "is missing so will be copied from " + mastertext + fieldscopied = True + finfo.addelem(field, ET.fromstring(ET.tostring(mfinfo[field][1]))) + elif action == "CopyArray" : + message = message + "Some values different Old: " + str(array) + " New: " + str(marray) + finfo.setelem(field, ET.fromstring(ET.tostring(melem))) + logger.log(message, "W") + else: + logger.log("Uncoded action: " + action + " - oops", "X") + + # Process lib.plist - currently just public.postscriptNames and glyph order fields which are all simple dicts or arrays + lib = font.lib + lchanged = False + + for field in libfields: + # Check the values + action = None; issue = ""; newval = "" + if field in mlib: + if field in lib: + if lib.getval(field) != mlib.getval(field): # will only work for arrays or dicts with simple values + action = "Updatefield" + else: + action = "Copyfield" + else: + action = "Error" if field == ("public.GlyphOrder", "public.postscriptNames") else "Warn" + issue = field + " not in " + mastertext + " lib.plist" + + # Process the actions, create log messages etc + if action is None or action == "Ignore": + pass + elif action == "Warn": + logger.log(field + " needs manual correction: " + issue, "W") + elif action == "Error": + logger.log(field + " needs manual correction: " + issue, "E") + elif action in ("Updatefield", "Copyfield"): # Updating actions + lchanged = True + message = field + updatemessage + if action == "Copyfield": + message = message + "is missing so will be copied from " + mastertext + lib.addelem(field, ET.fromstring(ET.tostring(mlib[field][1]))) + elif action == "Updatefield": + message = message + "Some values different" + lib.setelem(field, ET.fromstring(ET.tostring(mlib[field][1]))) + logger.log(message, "W") + else: + logger.log("Uncoded action: " + action + " - oops", "X") + + # Now update on disk + if not reportonly: + if args.normalize: + font.write(os.path.join(path, family + "-" + style + newfile + ".ufo")) + else: # Just update fontinfo and lib + if fchanged: + filen = "fontinfo" + newfile + ".plist" + logger.log("Writing updated fontinfo to " + filen, "P") + exists = True if os.path.isfile(os.path.join(font.ufodir, filen)) else False + UFO.writeXMLobject(finfo, font.outparams, font.ufodir, filen, exists, fobject=True) + if lchanged: + filen = "lib" + newfile + ".plist" + logger.log("Writing updated lib.plist to " + filen, "P") + exists = True if os.path.isfile(os.path.join(font.ufodir, filen)) else False + UFO.writeXMLobject(lib, font.outparams, font.ufodir, filen, exists, fobject=True) + + if fieldscopied : + message = "After updating, UFOsyncMeta will need to be re-run to validate these fields" if reportonly else "Re-run UFOsyncMeta to validate these fields" + logger.log("*** Some fields were missing and so copied from " + mastertext + ". " + message, "P") + + return + + +def openfont(params, path, family, style) : # Only try if directory exists + ufodir = os.path.join(path,family+"-"+style+".ufo") + font = UFO.Ufont(ufodir, params=params) if os.path.isdir(ufodir) else None + return font + + +def processnum(text, precision) : # Apply same processing to numbers that normalization will + if precision is not None: + val = round(float(text), precision) + if val == int(val) : val = int(val) # Removed trailing decimal .0 + text = str(val) + return text + + +def cmd() : execute("UFO",doit, argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psftuneraliases.py b/src/silfont/scripts/psftuneraliases.py new file mode 100644 index 0000000..bb4b484 --- /dev/null +++ b/src/silfont/scripts/psftuneraliases.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +__doc__ = '''Merge lookup and feature aliases into TypeTuner feature file''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Bob Hallissy' + +from silfont.core import execute +from xml.etree import ElementTree as ET +from fontTools import ttLib +import csv +import struct + +argspec = [ + ('input', {'help': 'Input TypeTuner feature file'}, {'type': 'infile'}), + ('output', {'help': 'Output TypeTuner feature file'}, {}), + ('-m','--mapping', {'help': 'Input csv mapping file'}, {'type': 'incsv'}), + ('-f','--ttf', {'help': 'Compiled TTF file'}, {}), + ('-l','--log',{'help': 'Optional log file'}, {'type': 'outfile', 'def': '_tuneraliases.log', 'optlog': True}), + ] + +def doit(args) : + logger = args.logger + + if args.mapping is None and args.ttf is None: + logger.log("One or both of -m and -f must be provided", "S") + featdoc = ET.parse(args.input) + root = featdoc.getroot() + if root.tag != 'all_features': + logger.log("Invalid TypeTuner feature file: missing root element", "S") + + # Whitespace to add after each new alias: + tail = '\n\t\t' + + # Find or add alliaes element + aliases = root.find('aliases') + if aliases is None: + aliases = ET.SubElement(root,'aliases') + aliases.tail = '\n' + + added = set() + duplicates = set() + def setalias(name, value): + # detect duplicate names in input + if name in added: + duplicates.add(name) + else: + added.add(name) + # modify existing or add new alias + alias = aliases.find('alias[@name="{}"]'.format(name)) + if alias is None: + alias = ET.SubElement(aliases, 'alias', {'name': name, 'value': value}) + alias.tail = tail + else: + alias.set('value', value) + + # Process mapping file if present: + if args.mapping: + # Mapping file is assumed to come from psfbuildfea, and should look like: + # lookupname,table,index + # e.g. DigitAlternates,GSUB,51 + for (name,table,value) in args.mapping: + setalias(name, value) + + # Process the ttf file if present + if args.ttf: + # Generate aliases for features. + # In this code featureID means the key used in FontUtils for finding the feature, e.g., "calt _2" + def dotable(t): # Common routine for GPOS and GSUB + currtag = None + currtagindex = None + flist = [] # list, in order, of (featureTag, featureID), per Font::TTF + for i in range(0,t.FeatureList.FeatureCount): + newtag = str(t.FeatureList.FeatureRecord[i].FeatureTag) + if currtag is None or currtag != newtag: + flist.append((newtag, newtag)) + currtag = newtag + currtagindex = 0 + else: + flist.append( (currtag, '{} _{}'.format(currtag, currtagindex))) + currtagindex += 1 + fslList = {} # dictionary keyed by feature_script_lang values returning featureID + for s in t.ScriptList.ScriptRecord: + currtag = str(s.ScriptTag) + # At present only looking at the dflt lang entries + for findex in s.Script.DefaultLangSys.FeatureIndex: + fslList['{}_{}_dflt'.format(flist[findex][0],currtag)] = flist[findex][1] + # Now that we have them all, add them in sorted order. + for name, value in sorted(fslList.items()): + setalias(name,value) + + # Open the TTF for processing + try: + f = ttLib.TTFont(args.ttf) + except Exception as e: + logger.log("Couldn't open font '{}' for reading : {}".format(args.ttf, str(e)),"S") + # Grab features from GSUB and GPOS + for tag in ('GSUB', 'GPOS'): + try: + dotable(f[tag].table) + except Exception as e: + logger.log("Failed to process {} table: {}".format(tag, str(e)), "W") + # Grab features from Graphite: + try: + for tag in sorted(f['Feat'].features.keys()): + if tag == '1': + continue + name = 'gr_' + tag + value = str(struct.unpack('>L', tag.encode())[0]) + setalias(name,value) + except Exception as e: + logger.log("Failed to process Feat table: {}".format(str(e)), "W") + + if len(duplicates): + logger.log("The following aliases defined more than once in input: {}".format(", ".join(sorted(duplicates))), "S") + + # Success. Write the result + featdoc.write(args.output, encoding='UTF-8', xml_declaration=True) + +def cmd() : execute(None,doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfufo2glyphs.py b/src/silfont/scripts/psfufo2glyphs.py new file mode 100644 index 0000000..3e82d86 --- /dev/null +++ b/src/silfont/scripts/psfufo2glyphs.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +__doc__ = '''Reads a designSpace file and create a Glyphs file from its linked ufos''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from silfont.core import execute, splitfn + +from glyphsLib import to_glyphs +from fontTools.designspaceLib import DesignSpaceDocument +import os + +argspec = [ + ('designspace', {'help': 'Input designSpace file'}, {'type': 'filename'}), + ('glyphsfile', {'help': 'Output glyphs file name', 'nargs': '?' }, {'type': 'filename', 'def': None}), + ('--glyphsformat', {'help': "Format for glyphs file (2 or 3)", 'default': "2"}, {}), + ('--nofea', {'help': 'Do not process features.fea', 'action': 'store_true', 'default': False}, {}), + # ('--nofixes', {'help': 'Bypass code fixing data', 'action': 'store_true', 'default': False}, {}), + ('-l', '--log', {'help': 'Log file'}, {'type': 'outfile', 'def': '_ufo2glyphs.log'})] + +# This is just bare-bones code at present so does the same as glyphsLib's ufo2glyphs! +# It is designed so that data could be massaged, if necessary, on the way. No such need has been found so far + +def doit(args): + glyphsfile = args.glyphsfile + logger = args.logger + gformat = args.glyphsformat + if gformat in ("2","3"): + gformat = int(gformat) + else: + logger.log("--glyphsformat must be 2 or 3", 'S') + if glyphsfile is None: + (path,base,ext) = splitfn(args.designspace) + glyphsfile = os.path.join(path, base + ".glyphs" ) + else: + (path, base, ext) = splitfn(glyphsfile) + backupname = os.path.join(path, base + "-backup.glyphs" ) + logger.log("Opening designSpace file", "I") + ds = DesignSpaceDocument() + ds.read(args.designspace) + if args.nofea: # Need to rename the features.fea files so they are not processed + origfeas = []; hiddenfeas = [] + for source in ds.sources: + origfea = os.path.join(source.path, "features.fea") + hiddenfea = os.path.join(source.path, "features.tmp") + if os.path.exists(origfea): + logger.log(f'Renaming {origfea} to {hiddenfea}', "I") + os.rename(origfea, hiddenfea) + origfeas.append(origfea) + hiddenfeas.append(hiddenfea) + else: + logger.log(f'No features.fea found in {source.path}') + logger.log("Now creating glyphs object", "I") + glyphsfont = to_glyphs(ds) + if args.nofea: # Now need to reverse renamimg of features.fea files + for i, origfea in enumerate(origfeas): + logger.log(f'Renaming {hiddenfeas[i]} back to {origfea}', "I") + os.rename(hiddenfeas[i], origfea) + glyphsfont.format_version = gformat + + if os.path.exists(glyphsfile): # Create a backup + logger.log("Renaming existing glyphs file to " + backupname, "I") + os.renames(glyphsfile, backupname) + logger.log("Writing glyphs file: " + glyphsfile, "I") + glyphsfont.save(glyphsfile) + +def cmd(): execute(None, doit, argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfufo2ttf.py b/src/silfont/scripts/psfufo2ttf.py new file mode 100644 index 0000000..11edc08 --- /dev/null +++ b/src/silfont/scripts/psfufo2ttf.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +__doc__ = 'Generate a ttf file without OpenType tables from a UFO' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Alan Ward' + +# Compared to fontmake it does not decompose glyphs or remove overlaps +# and curve conversion seems to happen in a different way. + +from silfont.core import execute +import defcon, ufo2ft.outlineCompiler, ufo2ft.preProcessor, ufo2ft.filters + +# ufo2ft v2.32.0b3 uses standard logging and the InstructionCompiler emits errors +# when a composite glyph is flattened, so filter out that message +# since it is expected in our workflow. +# The error is legitimate and results from trying to set the flags on components +# of composite glyphs from the UFO when it's unclear how to match the UFO components +# to the TTF components. +import logging +class FlattenErrFilter(logging.Filter): + def filter(self, record): + return not record.getMessage().startswith("Number of components differ between UFO and TTF") +logging.getLogger('ufo2ft.instructionCompiler').addFilter(FlattenErrFilter()) + +argspec = [ + ('iufo', {'help': 'Input UFO folder'}, {}), + ('ottf', {'help': 'Output ttf file name'}, {}), + ('--removeOverlaps', {'help': 'Merge overlapping contours', 'action': 'store_true'}, {}), + ('--decomposeComponents', {'help': 'Decompose componenets', 'action': 'store_true'}, {}), + ('-l', '--log', {'help': 'Optional log file'}, {'type': 'outfile', 'def': '_ufo2ttf.log', 'optlog': True})] + +PUBLIC_PREFIX = 'public.' + +def doit(args): + ufo = defcon.Font(args.iufo) + + # if style is Regular and there are no openTypeNameRecords defining the full name (ID=4), then + # add one so that "Regular" is omitted from the fullname + if ufo.info.styleName == 'Regular': + if ufo.info.openTypeNameRecords is None: + ufo.info.openTypeNameRecords = [] + fullNameRecords = [ nr for nr in ufo.info.openTypeNameRecords if nr['nameID'] == 4] + if not len(fullNameRecords): + ufo.info.openTypeNameRecords.append( { 'nameID': 4, 'platformID': 3, 'encodingID': 1, 'languageID': 1033, 'string': ufo.info.familyName } ) + +# args.logger.log('Converting UFO to ttf and compiling fea') +# font = ufo2ft.compileTTF(ufo, +# glyphOrder = ufo.lib.get(PUBLIC_PREFIX + 'glyphOrder'), +# useProductionNames = False) + + args.logger.log('Converting UFO to ttf without OT', 'P') + + # default arg value for TTFPreProcessor class: removeOverlaps = False, convertCubics = True + preProcessor = ufo2ft.preProcessor.TTFPreProcessor(ufo, removeOverlaps = args.removeOverlaps, convertCubics=True, + flattenComponents = True, + skipExportGlyphs = ufo.lib.get("public.skipExportGlyphs", [])) + + # Need to handle cases if filters that are used are set in com.github.googlei18n.ufo2ft.filters with lib.plist + dc = dtc = ftpos = None + for (i,filter) in enumerate(preProcessor.preFilters): + if isinstance(filter, ufo2ft.filters.decomposeComponents.DecomposeComponentsFilter): + dc = True + if isinstance(filter, ufo2ft.filters.decomposeTransformedComponents.DecomposeTransformedComponentsFilter): + dtc = True + if isinstance(filter, ufo2ft.filters.flattenComponents.FlattenComponentsFilter): + ftpos = i + # Add decomposeComponents if --decomposeComponents is used + if args.decomposeComponents and not dc: preProcessor.preFilters.append( + ufo2ft.filters.decomposeComponents.DecomposeComponentsFilter()) + # Add decomposeTransformedComponents if not already set via lib.plist + if not dtc: preProcessor.preFilters.append(ufo2ft.filters.decomposeTransformedComponents.DecomposeTransformedComponentsFilter()) + # Remove flattenComponents if set via lib.plist since we set it via flattenComponents = True when setting up the preprocessor + if ftpos: preProcessor.preFilters.pop(ftpos) + + glyphSet = preProcessor.process() + outlineCompiler = ufo2ft.outlineCompiler.OutlineTTFCompiler(ufo, + glyphSet=glyphSet, + glyphOrder=ufo.lib.get(PUBLIC_PREFIX + 'glyphOrder')) + font = outlineCompiler.compile() + + # handle uvs glyphs until ufo2ft does it for us. + if 'public.unicodeVariationSequences' not in ufo.lib: + uvsdict = getuvss(ufo) + if len(uvsdict): + from fontTools.ttLib.tables._c_m_a_p import cmap_format_14 + cmap_uvs = cmap_format_14(14) + cmap_uvs.platformID = 0 + cmap_uvs.platEncID = 5 + cmap_uvs.cmap = {} + cmap_uvs.uvsDict = uvsdict + font['cmap'].tables.append(cmap_uvs) + + args.logger.log('Saving ttf file', 'P') + font.save(args.ottf) + + args.logger.log('Done', 'P') + +def getuvss(ufo): + uvsdict = {} + uvs = ufo.lib.get('org.sil.variationSequences', None) + if uvs is not None: + for usv, dat in uvs.items(): + usvc = int(usv, 16) + pairs = [] + uvsdict[usvc] = pairs + for k, v in dat.items(): + pairs.append((int(k, 16), v)) + return uvsdict + for g in ufo: + uvs = getattr(g, 'lib', {}).get("org.sil.uvs", None) + if uvs is None: + continue + codes = [int(x, 16) for x in uvs.split()] + if codes[1] not in uvsdict: + uvsdict[codes[1]] = [] + uvsdict[codes[1]].append((codes[0], (g.name if codes[0] not in g.unicodes else None))) + return uvsdict + +def cmd(): execute(None, doit, argspec) +if __name__ == '__main__': cmd() diff --git a/src/silfont/scripts/psfversion.py b/src/silfont/scripts/psfversion.py new file mode 100644 index 0000000..560b63b --- /dev/null +++ b/src/silfont/scripts/psfversion.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +__doc__ = 'Display version info for pysilfont and dependencies' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +import sys, importlib +import silfont + +def cmd() : + + deps = ( # (module, used by, min recommended version) + ('defcon', '?', ''), + ('fontMath', '?', ''), + ('fontParts', '?', ''), + ('fontTools', '?', ''), + ('glyphsLib', '?', ''), + ('lxml','?', ''), + ('lz4', '?', ''), + ('mutatorMath', '?', ''), + ('odf', '?', ''), + ('palaso', '?', ''), + ('tabulate', '?', ''), + ('ufo2ft', '?', ''), + ) + + # Pysilfont info + print("Pysilfont " + silfont.__copyright__ + "\n") + print(" Version: " + silfont.__version__) + print(" Commands in: " + sys.argv[0][:-10]) + print(" Code running from: " + silfont.__file__[:-12]) + print(" using: Python " + sys.version.split(" \n")[0] + "\n") + + for dep in deps: + name = dep[0] + + try: + module = importlib.import_module(name) + path = module.__file__ + # Remove .py file name from end + pyname = path.split("/")[-1] + path = path[:-len(pyname)-1] + version = "No version info" + for attr in ("__version__", "version", "VERSION"): + if hasattr(module, attr): + version = getattr(module, attr) + break + except Exception as e: + etext = str(e) + if etext == "No module named '" + name + "'": + version = "Module is not installed" + else: + version = "Module import failed with " + etext + path = "" + + print('{:20} {:15} {}'.format(name + ":", version, path)) + + return + +if __name__ == "__main__": cmd() diff --git a/src/silfont/scripts/psfwoffit.py b/src/silfont/scripts/psfwoffit.py new file mode 100644 index 0000000..286133a --- /dev/null +++ b/src/silfont/scripts/psfwoffit.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +__doc__ = 'Convert font between ttf, woff, woff2' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2021 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'Bob Hallissy' + +from silfont.core import execute +from fontTools.ttLib import TTFont +from fontTools.ttLib.sfnt import WOFFFlavorData +from fontTools.ttLib.woff2 import WOFF2FlavorData +import os.path + +argspec = [ + ('infont', {'help': 'Source font file (can be ttf, woff, or woff2)'}, {}), + ('-m', '--metadata', {'help': 'file containing XML WOFF metadata', 'default': None}, {}), + ('--privatedata', {'help': 'file containing WOFF privatedata', 'default': None}, {}), + ('-v', '--version', {'help': 'woff font version number in major.minor', 'default': None}, {}), + ('--ttf', {'help': 'name of ttf file to be written', 'nargs': '?', 'default': None, 'const': '-'}, {}), + ('--woff', {'help': 'name of woff file to be written', 'nargs': '?', 'default': None, 'const': '-'}, {}), + ('--woff2', {'help': 'name of woff2 file to be written', 'nargs': '?', 'default': None, 'const': '-'}, {}), + ('-l', '--log', {'help': 'Log file'}, {'type': 'outfile', 'def': '_woffit.log'})] + +def doit(args): + logger = args.logger + infont = args.infont + font = TTFont(args.infont) + defaultpath = os.path.splitext(infont)[0] + inFlavor = font.flavor or 'ttf' + logger.log(f'input font {infont} is a {inFlavor}', 'I') + + # Read & parse version, if provided + flavorData = WOFFFlavorData() # Initializes all fields to None + + if args.version: + try: + version = float(args.version) + if version < 0: + raise ValueError('version cannot be negative') + flavorData.majorVersion, flavorData.minorVersion = map(int, format(version, '.3f').split('.')) + except: + logger.log(f'invalid version syntax "{args.version}": should be MM.mmm', 'S') + else: + try: + flavorData.majorVersion = font.flavorData.majorVersion + flavorData.minorVersion = font.flavorData.minorVersion + except: + # Pull version from head table + head = font['head'] + flavorData.majorVersion, flavorData.minorVersion =map(int, format(head.fontRevision, '.3f').split('.')) + + # Read metadata if provided, else get value from input font + if args.metadata: + try: + with open(args.metadata, 'rb') as f: + flavorData.metaData = f.read() + except: + logger.log(f'Unable to read file "{args.metadata}"', 'S') + elif inFlavor != 'ttf': + flavorData.metaData = font.flavorData.metaData + + # Same process for private data + if args.privatedata: + try: + with open(args.privatedata, 'rb') as f: + flavorData.privData = f.read() + except: + logger.log(f'Unable to read file "{args.privatedata}"', 'S') + elif inFlavor != 'ttf': + flavorData.privData = font.flavorData.privData + + if args.woff: + font.flavor = 'woff' + font.flavorData = flavorData + fname = f'{defaultpath}.{font.flavor}' if args.woff2 == '-' else args.woff + logger.log(f'Writing {font.flavor} font to "{fname}"', 'P') + font.save(fname) + + if args.woff2: + font.flavor = 'woff2' + font.flavorData = WOFF2FlavorData(data=flavorData) + fname = f'{defaultpath}.{font.flavor}' if args.woff2 == '-' else args.woff2 + logger.log(f'Writing {font.flavor} font to "{fname}"', 'P') + font.save(fname) + + if args.ttf: + font.flavor = None + font.flavorData = None + fname = f'{defaultpath}.ttf' if args.ttf == '-' else args.ttf + logger.log(f'Writing ttf font to "{fname}"', 'P') + font.save(fname) + + font.close() + +def cmd() : execute('FT',doit, argspec) +if __name__ == "__main__": cmd() + + + diff --git a/src/silfont/scripts/psfxml2compdef.py b/src/silfont/scripts/psfxml2compdef.py new file mode 100644 index 0000000..dc75d2c --- /dev/null +++ b/src/silfont/scripts/psfxml2compdef.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +__doc__ = 'convert composite definition file from XML format' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Rowe' + +from silfont.core import execute +from silfont.comp import CompGlyph +from xml.etree import ElementTree as ET + +# specify two parameters: input file (XML format), output file (single line format). +argspec = [ + ('input',{'help': 'Input file of CD in XML format'}, {'type': 'infile'}), + ('output',{'help': 'Output file of CD in single line format'}, {'type': 'outfile'}), + ('-l', '--log', {'help': 'Optional log file'}, {'type': 'outfile', 'def': '_xml2compdef.log', 'optlog': True})] + +def doit(args) : + cgobj = CompGlyph() + glyphcount = 0 + for g in ET.parse(args.input).getroot().findall('glyph'): + glyphcount += 1 + cgobj.CDelement = g + cgobj.CDline = None + cgobj.parsefromCDelement() + if cgobj.CDline != None: + args.output.write(cgobj.CDline+'\n') + else: + pass # error in glyph number glyphcount message + return + +def cmd() : execute(None,doit,argspec) +if __name__ == "__main__": cmd() diff --git a/src/silfont/ufo.py b/src/silfont/ufo.py new file mode 100644 index 0000000..d115ec1 --- /dev/null +++ b/src/silfont/ufo.py @@ -0,0 +1,1386 @@ +#!/usr/bin/env python3 +'Classes and functions for use handling Ufont UFO font objects in pysilfont scripts' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +from xml.etree import ElementTree as ET +import sys, os, shutil, filecmp, io, re +import warnings +import collections +import datetime +import silfont.core +import silfont.util as UT +import silfont.etutil as ETU + +_glifElemMulti = ('unicode', 'guideline', 'anchor') # glif elements that can occur multiple times +_glifElemF1 = ('advance', 'unicode', 'outline', 'lib') # glif elements valid in format 1 glifs (ie UFO2 glfis) + +# Define illegal characters and reserved names for makeFileName +_illegalChars = "\"*+/:><?[\]|" + chr(0x7F) +for i in range(0, 32): _illegalChars += chr(i) +_illegalChars = list(_illegalChars) +_reservedNames = "CON PRN AUX CLOCK$ NUL COM1 COM2 COM3 COM4 PT1 LPT2 LPT3".lower().split(" ") + +obsoleteLibKeys = [ # Used by "check and fix" + some scripts + "com.schriftgestaltung.blueFuzz", + "com.schriftgestaltung.blueScale", + "com.schriftgestaltung.blueShift", + "com.schriftgestaltung.customValue", + "com.schriftgestaltung.Disable Last Change", + "com.schriftgestaltung.disablesAutomaticAlignment", + "com.schriftgestaltung.disablesLastChange", + "com.schriftgestaltung.DisplayStrings", + "com.schriftgestaltung.font.Disable Last Change", + "com.schriftgestaltung.font.glyphOrder", + "com.schriftgestaltung.font.license", + "com.schriftgestaltung.useNiceNames", + "org.sil.glyphsappversion", + "UFOFormat"] + +class _Ucontainer(object): + # Parent class for other objects (eg Ulayer) + def __init__(self): + self._contents = {} + + # Define methods so it acts like an immutable container + # (changes should be made via object functions etc) + def __len__(self): + return len(self._contents) + + def __getitem__(self, key): + return self._contents[key] + + def __iter__(self): + return iter(self._contents) + + def get(self, key, default=None): + return self._contents.get(key, default=default) + + def keys(self): + return self._contents.keys() + + +class _plist(object): + # Used for common plist methods inherited by Uplist and Ulib classes + + def addval(self, key, valuetype, value): # For simple single-value elements - use addelem for dicts or arrays + if valuetype not in ("integer", "real", "string"): + self.font.logger.log("addval() can only be used with simple elements", "X") + if key in self._contents: self.font.logger.log("Attempt to add duplicate key " + key + " to plist", "X") + dict = self.etree[0] + + keyelem = ET.Element("key") + keyelem.text = key + dict.append(keyelem) + + valelem = ET.Element(valuetype) + valelem.text = str(value) + dict.append(valelem) + + self._contents[key] = [keyelem, valelem] + + def setval(self, key, valuetype, value): # For simple single-value elements - use setelem for dicts or arrays + if valuetype not in ("integer", "real", "string"): + self.font.logger.log("setval() can only be used with simple elements", "X") + if key in self._contents: + self._contents[key][1].text = str(value) + else: + self.addval(key, valuetype, value) + + def getval(self, key, default=None): # Returns a value for integer, real, string, true, false, dict or array keys or None for other keys + elem = self._contents.get(key, [None, None])[1] + if elem is None: + return default + return self._valelem(elem) + + def _valelem(self, elem): # Used by getval to recursively process dict and array elements + if elem.tag == "integer": return int(elem.text) + elif elem.tag == "real": return float(elem.text) + elif elem.tag == "string": return elem.text + elif elem.tag == "true": return True + elif elem.tag == "false": return False + elif elem.tag == "array": + array = [] + for subelem in elem: array.append(self._valelem(subelem)) + return array + elif elem.tag == "dict": + dict = {} + for i in range(0, len(elem), 2): dict[elem[i].text] = self._valelem(elem[i + 1]) + return dict + else: + return None + + def remove(self, key): + item = self._contents[key] + self.etree[0].remove(item[0]) + self.etree[0].remove(item[1]) + del self._contents[key] + + def addelem(self, key, element): # For non-simple elements (eg arrays) the calling script needs to build the etree element + if key in self._contents: self.font.logger.log("Attempt to add duplicate key " + key + " to plist", "X") + dict = self.etree[0] + + keyelem = ET.Element("key") + keyelem.text = key + dict.append(keyelem) + dict.append(element) + + self._contents[key] = [keyelem, element] + + def setelem(self, key, element): + if key in self._contents: self.remove(key) + self.addelem(key, element) + + +class Uelement(_Ucontainer): + # Class for an etree element. Mainly used as a parent class + # For each tag in the element, returns list of sub-elements with that tag + def __init__(self, element): + self.element = element + self.reindex() + + def reindex(self): + self._contents = collections.defaultdict(list) + for e in self.element: + self._contents[e.tag].append(e) + + def remove(self, subelement): + self._contents[subelement.tag].remove(subelement) + self.element.remove(subelement) + + def append(self, subelement): + self._contents[subelement.tag].append(subelement) + self.element.append(subelement) + + def insert(self, index, subelement): + self._contents[subelement.tag].insert(index, subelement) + self.element.insert(index, subelement) + + def replace(self, index, subelement): + oldsubelement = self.element[index] + cindex = self._contents[subelement.tag].index(oldsubelement) + self._contents[subelement.tag][cindex] = subelement + self.element[index] = subelement + + +class UtextFile(object): + # Generic object for handling non-xml text files + def __init__(self, font, dirn, filen): + self.type = "textfile" + self.font = font + self.filen = filen + self.dirn = dirn + if dirn == font.ufodir: + dtree = font.dtree + else: + dtree = font.dtree.subtree(dirn) + if not dtree: font.logger.log("Missing directory " + dirn, "X") + if filen not in dtree: + dtree[filen] = UT.dirTreeItem(added=True) + dtree[filen].setinfo(read=True) + dtree[filen].fileObject = self + dtree[filen].fileType = "text" + + def write(self, dtreeitem, dir, ofilen, exists): + # For now just copies source to destination if changed + inpath = os.path.join(self.dirn, self.filen) + changed = True + if exists: changed = not (filecmp.cmp(inpath, os.path.join(dir, self.filen))) + if changed: + try: + shutil.copy2(inpath, dir) + except Exception as e: + print(e) + sys.exit(1) + dtreeitem.written = True + +class Udirectory(object): + # Generic object for handling directories - used for data and images + def __init__(self, font, parentdir, dirn): + self.type = "directory" + self.font = font + self.parentdir = parentdir + self.dirn = dirn + if parentdir != font.ufodir: + self.font.logger.log("Currently Udir only supports top-level directories", "X") + dtree = font.dtree + if dirn not in dtree: + self.font.logger.log("Udir directory " + dirn + " does not exist", "X") + dtree[dirn].setinfo(read=True) + dtree[dirn].fileObject = self + dtree[dirn].fileType = "directory" + + def write(self, dtreeitem, oparentdir): + # For now just copies source to destination + if self.parentdir == oparentdir: return # No action needed + inpath = os.path.join(self.parentdir, self.dirn) + outpath = os.path.join(oparentdir, self.dirn) + try: + if os.path.isdir(outpath): + shutil.rmtree(outpath) + shutil.copytree(inpath, outpath) + except Exception as e: + print(e) + sys.exit(1) + dtreeitem.written = True + +class Ufont(object): + """ Object to hold all the data from a UFO""" + + def __init__(self, ufodir, logger=None, params=None): + if logger is not None and params is not None: + params.logger.log("Only supply a logger if params not set (since that has one)", "X") + if params is None: + params = silfont.core.parameters() + if logger is not None: params.logger = logger + self.params = params + self.logger = params.logger + logger = self.logger + self.ufodir = ufodir + logger.log('Reading UFO: ' + ufodir, 'P') + if not os.path.isdir(ufodir): + logger.log(ufodir + " is not a directory", "S") + # Read list of files and folders + self.dtree = UT.dirTree(ufodir) + # Read metainfo (which must exist) + self.metainfo = self._readPlist("metainfo.plist") + self.UFOversion = self.metainfo["formatVersion"][1].text + # Read lib.plist then process pysilfont parameters if present + libparams = {} + if "lib.plist" in self.dtree: + self.lib = self._readPlist("lib.plist") + if "org.sil.pysilfontparams" in self.lib: + elem = self.lib["org.sil.pysilfontparams"][1] + if elem.tag != "array": + logger.log("Invalid parameter XML lib.plist - org.sil.pysilfontparams must be an array", "S") + for param in elem: + parn = param.tag + if not (parn in params.paramclass) or params.paramclass[parn] not in ("outparams", "ufometadata"): + logger.log("lib.plist org.sil.pysilfontparams must only contain outparams or ufometadata values: " + parn + " invalid", "S") + libparams[parn] = param.text + # Create font-specific parameter set (with updates from lib.plist) Prepend names with ufodir to ensure uniqueness if multiple fonts open + params.addset(ufodir + "lib", "lib.plist in " + ufodir, inputdict=libparams) + if "command line" in params.sets: + params.sets[ufodir + "lib"].updatewith("command line", log=False) # Command line parameters override lib.plist ones + copyset = "main" if "main" in params.sets else "default" + params.addset(ufodir, copyset=copyset) + params.sets[ufodir].updatewith(ufodir + "lib", sourcedesc="lib.plist") + self.paramset = params.sets[ufodir] + # Validate specific parameters + if self.paramset["UFOversion"] not in ("", "2", "3"): logger.log("UFO version must be 2 or 3", "S") + if sorted(self.paramset["glifElemOrder"]) != sorted(self.params.sets["default"]["glifElemOrder"]): + logger.log("Invalid values for glifElemOrder", "S") + + # Create outparams based on values in paramset, building attriborders from separate attriborders.<type> parameters. + self.outparams = {"attribOrders": {}} + for parn in params.classes["outparams"]: + value = self.paramset[parn] + if parn[0:12] == 'attribOrders': + elemname = parn.split(".")[1] + self.outparams["attribOrders"][elemname] = ETU.makeAttribOrder(value) + else: + self.outparams[parn] = value + if self.outparams["UFOversion"] == "": self.outparams["UFOversion"] = self.UFOversion + + # Set flags for checking and fixing metadata + cf = self.paramset["checkfix"].lower() + if cf not in ("check", "fix", "none", ""): logger.log("Invalid value '" + cf + "' for checkfix parameter", "S") + + self.metacheck = True if cf in ("check", "fix") else False + self.metafix = True if cf == "fix" else False + if "fontinfo.plist" not in self.dtree: + logger.log("fontinfo.plist missing so checkfix routines can't be run", "E") + self.metacheck = False + self.metafix = False + + # Read other top-level plists + if "fontinfo.plist" in self.dtree: self.fontinfo = self._readPlist("fontinfo.plist") + if "groups.plist" in self.dtree: self.groups = self._readPlist("groups.plist") + if "kerning.plist" in self.dtree: self.kerning = self._readPlist("kerning.plist") + createlayercontents = False + if self.UFOversion == "2": # Create a dummy layer contents so 2 & 3 can be handled the same + createlayercontents = True + else: + if "layercontents.plist" in self.dtree: + self.layercontents = self._readPlist("layercontents.plist") + else: + logger.log("layercontents.plist missing - one will be created", "W") + createlayercontents = True + if createlayercontents: + if "glyphs" not in self.dtree: logger.log('No glyphs directory in font', "S") + self.layercontents = Uplist(font=self) + self.dtree['layercontents.plist'] = UT.dirTreeItem(read=True, added=True, fileObject=self.layercontents, + fileType="xml") + dummylc = "<plist>\n<array>\n<array>\n<string>public.default</string>\n<string>glyphs</string>\n</array>\n</array>\n</plist>" + self.layercontents.etree = ET.fromstring(dummylc) + self.layercontents.populate_dict() + + # Process features.fea + if "features.fea" in self.dtree: + self.features = UfeatureFile(self, ufodir, "features.fea") + # Process the glyphs directories) + self.layers = [] + self.deflayer = None + for i in sorted(self.layercontents.keys()): + layername = self.layercontents[i][0].text + layerdir = self.layercontents[i][1].text + logger.log("Processing Glyph Layer " + str(i) + ": " + layername + layerdir, "I") + layer = Ulayer(layername, layerdir, self) + if layer: + self.layers.append(layer) + if layername == "public.default": self.deflayer = layer + else: + logger.log("Glyph directory " + layerdir + " missing", "S") + if self.deflayer is None: logger.log("No public.default layer", "S") + # Process other directories + if "images" in self.dtree: + self.images = Udirectory(self,ufodir, "images") + if "data" in self.dtree: + self.data = Udirectory(self, ufodir, "data") + + # Run best practices check and fix routines + if self.metacheck: + initwarnings = logger.warningcount + initerrors = logger.errorcount + + fireq = ("ascender", "copyright", "descender", "familyName", "openTypeNameManufacturer", + "styleName", "unitsPerEm", "versionMajor", "versionMinor") + fiwarnifmiss = ("capHeight", "copyright", "openTypeNameDescription", "openTypeNameDesigner", + "openTypeNameDesignerURL", "openTypeNameLicense", "openTypeNameLicenseURL", + "openTypeNameManufacturerURL", "openTypeOS2CodePageRanges", + "openTypeOS2UnicodeRanges", "openTypeOS2VendorID", + "openTypeOS2WeightClass", "openTypeOS2WinAscent", "openTypeOS2WinDescent") + fiwarnifnot = {"unitsPerEm": (1000, 2048), + "styleMapStyleName": ("regular", "bold", "italic", "bold italic")}, + fiwarnifpresent = ("note",) + fidel = ("macintoshFONDFamilyID", "macintoshFONDName", "openTypeNameCompatibleFullName", + "openTypeGaspRangeRecords", "openTypeHheaCaretOffset", + "openTypeOS2FamilyClass", "postscriptForceBold", "postscriptIsFixedPitch", + "postscriptBlueFuzz", "postscriptBlueScale", "postscriptBlueShift", "postscriptWeightName", + "year") + fidelifempty = ("guidelines", "postscriptBlueValues", "postscriptFamilyBlues", "postscriptFamilyOtherBlues", + "postscriptOtherBlues") + fiint = ("ascender", "capHeight", "descender", "postscriptUnderlinePosition", + "postscriptUnderlineThickness", "unitsPerEm", "xHeight") + ficapitalize = ("styleMapFamilyName", "styleName") + fisetifmissing = {} + fisettoother = {"openTypeHheaAscender": "ascender", "openTypeHheaDescender": "descender", + "openTypeNamePreferredFamilyName": "familyName", + "openTypeNamePreferredSubfamilyName": "styleName", "openTypeOS2TypoAscender": "ascender", + "openTypeOS2TypoDescender": "descender"} + fisetto = {"openTypeHheaLineGap": 0, "openTypeOS2TypoLineGap": 0, "openTypeOS2WidthClass": 5, + "openTypeOS2Selection": [7], "openTypeOS2Type": []} # Other values are added below + + libdel = ("com.fontlab.v2.tth", "com.typemytype.robofont.italicSlantOffset") + libsetto = {"com.schriftgestaltung.customParameter.GSFont.disablesAutomaticAlignment": True, + "com.schriftgestaltung.customParameter.GSFont.disablesLastChange": True} + libwarnifnot = {"com.schriftgestaltung.customParameter.GSFont.useNiceNames": False} + libwarnifmissing = ("public.glyphOrder",) + + # fontinfo.plist checks + logger.log("Checking fontinfo.plist metadata", "P") + + # Check required fields, some of which are needed for remaining checks + missing = [] + for key in fireq: + if key not in self.fontinfo or self.fontinfo.getval(key) is None: missing.append(key) + # Collect values for constructing other fields, setting dummy values when missing and in check-only mode + dummies = False + storedvals = {} + for key in ("ascender", "copyright", "descender", "familyName", "styleName", "openTypeNameManufacturer", "versionMajor", "versionMinor"): + if key in self.fontinfo and self.fontinfo.getval(key) is not None: + storedvals[key] = self.fontinfo.getval(key) + if key == "styleName": + sn = storedvals[key] + sn = re.sub(r"(\w)(Italic)", r"\1 \2", sn) # Add a space before Italic if missing + # Capitalise first letter of words + sep = b' ' if type(sn) is bytes else ' ' + sn = sep.join(s[:1].upper() + s[1:] for s in sn.split(sep)) + if sn != storedvals[key]: + if self.metafix: + self.fontinfo.setval(key, "string", sn) + logmess = " updated " + else: + logmess = " would be updated " + self.logchange(logmess, key, storedvals[key], sn) + storedvals[key] = sn + if key in ("ascender", "descender"): + storedvals[key] = int(storedvals[key]) + else: + dummies = True + if key in ("ascender", "descender", "versionMajor", "versionMinor"): + storedvals[key] = 999 + else: + storedvals[key] = "Dummy" + if missing: + logtype = "S" if self.metafix else "W" + logger.log("Required fields missing from fontinfo.plist: " + str(missing), logtype) + if dummies: + logger.log("Checking will continue with values of 'Dummy' or 999 for missing fields", "W") + # Construct values for certain fields + value = storedvals["openTypeNameManufacturer"] + ": " + storedvals["familyName"] + " " + value = value + storedvals["styleName"] + ": " + datetime.datetime.now().strftime("%Y") + fisetto["openTypeNameUniqueID"] = value +# fisetto["openTypeOS2WinDescent"] = -storedvals["descender"] + if "openTypeNameVersion" not in self.fontinfo: + fisetto["openTypeNameVersion"] = "Version " + str(storedvals["versionMajor"]) + "."\ + + str(storedvals["versionMinor"]) + if "openTypeOS2WeightClass" not in self.fontinfo: + sn = storedvals["styleName"] + sn2wc = {"Regular": 400, "Italic": 400, "Bold": 700, "BoldItalic": 700} + if sn in sn2wc: fisetto["openTypeOS2WeightClass"] = sn2wc[sn] + if "xHeight" not in self.fontinfo: + fisetto["xHeight"] = int(storedvals["ascender"] * 0.6) + if "openTypeOS2Selection" in self.fontinfo: # If already present, need to ensure bit 7 is set + fisetto["openTypeOS2Selection"] = sorted(list(set(self.fontinfo.getval("openTypeOS2Selection") + [7]))) + + for key in fisetifmissing: + if key not in self.fontinfo: + fisetto[key] = fisetifmissing[key] + + changes = 0 + # Warn about missing fields + for key in fiwarnifmiss: + if key not in self.fontinfo: + logmess = key + " is missing from fontinfo.plist" + logger.log(logmess, "W") + # Warn about bad values + for key in fiwarnifnot: + if key in self.fontinfo: + value = self.fontinfo.getval(key) + if value not in fiwarnifnot[key]: + logger.log(key + " should be one of " + str(fiwarnifnot[key]), "W") + # Warn about keys where use of discouraged + for key in fiwarnifpresent: + if key in self.fontinfo: + logger.log(key + " is present - it's use is discouraged") + + # Now do all remaining checks - which will lead to values being changed + for key in fidel + fidelifempty: + if key in self.fontinfo: + old = self.fontinfo.getval(key) + if not(key in fidelifempty and old != []): # Delete except for non-empty fidelifempty + if self.metafix: + self.fontinfo.remove(key) + logmess = " removed from fontinfo. " + else: + logmess = " would be removed from fontinfo " + self.logchange(logmess, key, old, None) + changes += 1 + + # Set to integer values + for key in fiint: + if key in self.fontinfo: + old = self.fontinfo.getval(key) + if old != int(old): + new = int(old) + if self.metafix: + self.fontinfo.setval(key, "integer", new) + logmess = " updated " + else: + logmess = " would be updated " + self.logchange(logmess, key, old, new) + changes += 1 + # Capitalize words + for key in ficapitalize: + if key in self.fontinfo: + old = self.fontinfo.getval(key) + sep = b' ' if type(old) is bytes else ' ' + new = sep.join(s[:1].upper() + s[1:] for s in old.split(sep)) # Capitalise words + if new != old: + if self.metafix: + self.fontinfo.setval(key, "string", new) + logmess = " uppdated " + else: + logmess = " would be uppdated " + self.logchange(logmess, key, old, new) + changes += 1 + # Set to specific values + for key in list(fisetto.keys()) + list(fisettoother.keys()): + if key in self.fontinfo: + old = self.fontinfo.getval(key) + logmess = " updated " + else: + old = None + logmess = " added " + if key in fisetto: + new = fisetto[key] + else: + new = storedvals[fisettoother[key]] + if new != old: + if self.metafix: + if isinstance(new, list): # Currently only integer arrays + array = ET.Element("array") + for val in new: # Only covers integer at present for openTypeOS2Selection + ET.SubElement(array, "integer").text = val + self.fontinfo.setelem(key, array) + else: # Does not cover real at present + valtype = "integer" if isinstance(new, int) else "string" + self.fontinfo.setval(key, valtype, new) + else: + logmess = " would be" + logmess + self.logchange(logmess, key, old, new) + changes += 1 + # Specific checks + if "italicAngle" in self.fontinfo: + old = self.fontinfo.getval("italicAngle") + if old == 0: # Should be deleted if 0 + logmess = " removed since it is 0 " + if self.metafix: + self.fontinfo.remove("italicAngle") + else: + logmess = " would be" + logmess + self.logchange(logmess, "italicAngle", old, None) + changes += 1 + if "versionMajor" in self.fontinfo: # If missing, an error will already have been reported... + vm = self.fontinfo.getval("versionMajor") + if vm == 0: logger.log("versionMajor is 0", "W") + + # lib.plist checks + if "lib" not in self.__dict__: + logger.log("lib.plist missing so not checked by check & fix routines", "E") + else: + logger.log("Checking lib.plist metadata", "P") + + for key in libdel: + if key in self.lib: + old = self.lib.getval(key) + if self.metafix: + self.lib.remove(key) + logmess = " removed from lib.plist. " + else: + logmess = " would be removed from lib.plist " + self.logchange(logmess, key, old, None) + changes += 1 + + for key in libsetto: + if key in self.lib: + old = self.lib.getval(key) + logmess = " updated " + else: + old = None + logmess = " added " + new = libsetto[key] + if new != old: + if self.metafix: + # Currently just supports True. See fisetto for adding other types + if new == True: + self.lib.setelem(key, ET.fromstring("<true/>")) + else: # Does not cover real at present + logger.log("Invalid value type for libsetto", "X") + else: + logmess = " would be" + logmess + self.logchange(logmess, key, old, new) + changes += 1 + for key in libwarnifnot: + value = self.lib.getval(key) if key in self.lib else None + if value != libwarnifnot[key]: + addmess = "; currently missing" if value is None else "; currently set to " + str(value) + logger.log(key + " should normally be " + str(libwarnifnot[key]) + addmess, "W") + + for key in libwarnifmissing: + if key not in self.lib: + logger.log(key + " is missing from lib.plist", "W") + + logmess = " deleted - obsolete key" if self.metafix else " would be deleted - obsolete key" + for key in obsoleteLibKeys: # For obsolete keys that have been added historically by some tools + if key in self.lib: + old = self.lib.getval(key) + if self.metafix: self.lib.remove(key) + self.logchange(logmess,key,old,None) + changes += 1 + + # Show check&fix summary + warnings = logger.warningcount - initwarnings - changes + errors = logger.errorcount - initerrors + if errors or warnings or changes: + changemess = ", Changes made: " if self.metafix else ", Changes to make: " + logger.log("Check & fix results:- Errors: " + str(errors) + changemess + str(changes) + + ", Other warnings: " + str(warnings), "P") + if logger.scrlevel not in "WIV": logger.log("See log file for details", "P") + if missing and not self.metafix: + logger.log("**** Since some required fields were missing, checkfix=fix would fail", "P") + else: + logger.log("Check & Fix ran cleanly", "P") + + def _readPlist(self, filen): + if filen in self.dtree: + plist = Uplist(font=self, filen=filen) + self.dtree[filen].setinfo(read=True, fileObject=plist, fileType="xml") + return plist + else: + self.logger.log(filen + " does not exist", "S") + + def write(self, outdir): + # Write UFO out to disk, based on values set in self.outparams + self.logger.log("Processing font for output", "P") + if not os.path.exists(outdir): + try: + os.mkdir(outdir) + except Exception as e: + print(e) + sys.exit(1) + if not os.path.isdir(outdir): + self.logger.log(outdir + " not a directory", "S") + + # If output UFO already exists, need to open so only changed files are updated and redundant files deleted + if outdir == self.ufodir: # In special case of output and input being the same, simply copy the input font + odtree = UT.dirTree(outdir) + else: + if not os.path.exists(outdir): # If outdir does not exist, create it + try: + os.mkdir(outdir) + except Exception as e: + print(e) + sys.exit(1) + odtree = {} + else: + if not os.path.isdir(outdir): self.logger.log(outdir + " not a directory", "S") + dirlist = os.listdir(outdir) + if dirlist == []: # Outdir is empty + odtree = {} + else: + self.logger.log("Output UFO already exists - reading for comparison", "P") + odtree = UT.dirTree(outdir) + # Update version info etc + UFOversion = self.outparams["UFOversion"] + self.metainfo["formatVersion"][1].text = str(UFOversion) + self.metainfo["creator"][1].text = "org.sil.scripts.pysilfont" + + # Set standard UFO files for output + dtree = self.dtree + setFileForOutput(dtree, "metainfo.plist", self.metainfo, "xml") + if "fontinfo" in self.__dict__: setFileForOutput(dtree, "fontinfo.plist", self.fontinfo, "xml") + if "groups" in self.__dict__: # With groups, sort by glyph name + for gname in list(self.groups): + group = self.groups.getval(gname) + elem = ET.Element("array") + for glyph in sorted(group): + ET.SubElement(elem, "string").text = glyph + self.groups.setelem(gname, elem) + setFileForOutput(dtree, "groups.plist", self.groups, "xml") + if "kerning" in self.__dict__: setFileForOutput(dtree, "kerning.plist", self.kerning, "xml") + if "lib" in self.__dict__: setFileForOutput(dtree, "lib.plist", self.lib, "xml") + if UFOversion == "3": + # Sort layer contents by layer name + lc = self.layercontents + lcindex = {lc[x][0].text: lc[x] for x in lc} # index on layer name + for (x, name) in enumerate(sorted(lcindex)): + lc.etree[0][x] = lcindex[name] # Replace array elements in new order + setFileForOutput(dtree, "layercontents.plist", self.layercontents, "xml") + if "features" in self.__dict__: setFileForOutput(dtree, "features.fea", self.features, "text") + # Set glyph layers for output + for layer in self.layers: layer.setForOutput() + + # Write files to disk + + self.logger.log("Writing font to " + outdir, "P") + + changes = writeToDisk(dtree, outdir, self, odtree) + if changes: # Need to update openTypeHeadCreated if there have been any changes to the font + if "fontinfo" in self.__dict__: + self.fontinfo.setval("openTypeHeadCreated", "string", + datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S")) + self.fontinfo.outxmlstr="" # Need to reset since writeXMLobject has already run once + writeXMLobject(self.fontinfo, self.outparams, outdir, "fontinfo.plist", True, fobject=True) + + def addfile(self, filetype): # Add empty plist file for optional files + if filetype not in ("fontinfo", "groups", "kerning", "lib"): self.logger.log("Invalid file type to add", "X") + if filetype in self.__dict__: self.logger.log("File already in font", "X") + obj = Uplist(font=self) + setattr(self, filetype, obj) + self.dtree[filetype + '.plist'] = UT.dirTreeItem(read=True, added=True, fileObject=obj, fileType="xml") + obj.etree = ET.fromstring("<plist>\n<dict/>\n</plist>") + + def logchange(self, logmess, key, old, new): + oldstr = str(old) if len(str(old)) < 22 else str(old)[0:20] + "..." + newstr = str(new) if len(str(new)) < 22 else str(new)[0:20] + "..." + logmess = key + logmess + if old is None: + logmess = logmess + " New value: " + newstr + else: + if new is None: + logmess = logmess + " Old value: " + oldstr + else: + logmess = logmess + " Old value: " + oldstr + ", new value: " + newstr + self.logger.log(logmess, "W") + # Extra verbose logging + if len(str(old)) > 21: + self.logger.log("Full old value: " + str(old), "I") + if len(str(new)) > 21: + self.logger.log("Full new value: " + str(new), "I") + otype = "string" if isinstance(old, (bytes, str)) else type(old).__name__ # To produce consistent reporting + ntype = "string" if isinstance(new, (bytes, str)) else type(new).__name__ # with Python 2 & 3 + self.logger.log("Types: Old - " + otype + ", New - " + ntype, "I") + +class Ulayer(_Ucontainer): + def __init__(self, layername, layerdir, font): + self._contents = collections.OrderedDict() + self.dtree = font.dtree.subTree(layerdir) + font.dtree[layerdir].read = True + self.layername = layername + self.layerdir = layerdir + self.font = font + fulldir = os.path.join(font.ufodir, layerdir) + self.contents = Uplist(font=font, dirn=fulldir, filen="contents.plist") + self.dtree["contents.plist"].setinfo(read=True, fileObject=self.contents, fileType="xml") + + if font.UFOversion == "3": + if 'layerinfo.plist' in self.dtree: + self.layerinfo = Uplist(font=font, dirn=fulldir, filen="layerinfo.plist") + self.dtree["layerinfo.plist"].setinfo(read=True, fileObject=self.layerinfo, fileType="xml") + + for glyphn in sorted(self.contents.keys()): + glifn = self.contents[glyphn][1].text + if glifn in self.dtree: + glyph = Uglif(layer=self, filen=glifn) + self._contents[glyphn] = glyph + self.dtree[glifn].setinfo(read=True, fileObject=glyph, fileType="xml") + if glyph.name != glyphn: + super(Uglif, glyph).__setattr__("name", glyphn) # Need to use super to bypass normal glyph renaming logic + self.font.logger.log("Glyph names in glif and contents.plist did not match for " + glyphn + "; corrected", "W") + else: + self.font.logger.log("Missing glif " + glifn + " in " + fulldir, "S") + + def setForOutput(self): + + UFOversion = self.font.outparams["UFOversion"] + convertg2f1 = True if UFOversion == "2" or self.font.outparams["format1Glifs"] else False + dtree = self.font.dtree.subTree(self.layerdir) + if self.font.outparams["renameGlifs"]: self.renameGlifs() + + setFileForOutput(dtree, "contents.plist", self.contents, "xml") + if "layerinfo" in self.__dict__ and UFOversion == "3": + setFileForOutput(dtree, "layerinfo.plist", self.layerinfo, "xml") + + for glyphn in self: + glyph = self._contents[glyphn] + if convertg2f1: glyph.convertToFormat1() + if glyph["advance"] is not None: + if glyph["advance"].width is None and glyph["advance"].height is None: glyph.remove("advance") + # Normalize so that, when both exist, components come before contour + outline = glyph["outline"] + if len(outline.components) > 0 and list(outline)[0] == "contour": + # Need to move components to the front... + contours = outline.contours + components = outline.components + oldcontours = list(contours) # Easiest way to 'move' components is to delete contours then append back at the end + for contour in oldcontours: outline.removeobject(contour, "contour") + for contour in oldcontours: outline.appendobject(contour, "contour") + + setFileForOutput(dtree, glyph.filen, glyph, "xml") + + def renameGlifs(self): + namelist = [] + for glyphn in sorted(self.keys()): + glyph = self._contents[glyphn] + filename = makeFileName(glyphn, namelist) + namelist.append(filename.lower()) + filename += ".glif" + if filename != glyph.filen: + self.renameGlif(glyphn, glyph, filename) + + def renameGlif(self, glyphn, glyph, newname): + self.font.logger.log("Renaming glif for " + glyphn + " from " + glyph.filen + " to " + newname, "I") + self.dtree.removedfiles[glyph.filen] = newname # Track so original glif does not get reported as invalid + glyph.filen = newname + self.contents[glyphn][1].text = newname + + def addGlyph(self, glyph): + glyphn = glyph.name + if glyphn in self._contents: self.font.logger.log(glyphn + " already in font", "X") + self._contents[glyphn] = glyph + # Set glif name + glifn = makeFileName(glyphn) + names = [] + while glifn in self.contents: # need to check for duplicate glif names + names.append(glifn) + glifn = makeFileName(glyphn, names) + glifn += ".glif" + glyph.filen = glifn + # Add to contents.plist and dtree + self.contents.addval(glyphn, "string", glifn) + self.dtree[glifn] = UT.dirTreeItem(read=False, added=True, fileObject=glyph, fileType="xml") + + def delGlyph(self, glyphn): + self.dtree.removedfiles[self[glyphn].filen] = "deleted" # Track so original glif does not get reported as invalid + del self._contents[glyphn] + self.contents.remove(glyphn) + + +class Uplist(ETU.xmlitem, _plist): + def __init__(self, font=None, dirn=None, filen=None, parse=True): + if dirn is None and font: dirn = font.ufodir + logger = font.logger if font else silfont.core.loggerobj() + ETU.xmlitem.__init__(self, dirn, filen, parse, logger) + self.type = "plist" + self.font = font + self.outparams = None + if filen and dirn: self.populate_dict() + + def populate_dict(self): + self._contents.clear() # Clear existing contents, if any + pl = self.etree[0] + if pl.tag == "dict": + for i in range(0, len(pl), 2): + key = pl[i].text + self._contents[key] = [pl[i], pl[i + 1]] # The two elements for the item + else: # Assume array of 2 element arrays (eg layercontents.plist) + for i in range(len(pl)): + self._contents[i] = pl[i] + + +class Uglif(ETU.xmlitem): + # Unlike plists, glifs can have multiples of some sub-elements (eg anchors) so create lists for those + + def __init__(self, layer, filen=None, parse=True, name=None, format=None): + dirn = os.path.join(layer.font.ufodir, layer.layerdir) + ETU.xmlitem.__init__(self, dirn, filen, parse, layer.font.logger) # Will read item from file if dirn and filen both present + self.type = "glif" + self.layer = layer + self.format = format if format else '2' + self.name = name + self.outparams = None + self.glifElemOrder = self.layer.font.outparams["glifElemOrder"] + # Set initial values for sub-objects + for elem in self.glifElemOrder: + if elem in _glifElemMulti: + self._contents[elem] = [] + else: + self._contents[elem] = None + if self.etree is not None: self.process_etree() + + def __setattr__(self, name, value): + if name == "name" and getattr(self, "name", None): # Existing glyph name is being changed + oname = self.name + if value in self.layer._contents: self.layer.font.logger.log(name + " already in font", "X") + # Update the _contents dictionary + del self.layer._contents[oname] + self.layer._contents[value] = self + # Set glif name + glifn = makeFileName(value) + names = [] + while glifn in self.layer.contents: # need to check for duplicate glif names + names.append(glifn) + glifn = makeFileName(value, names) + glifn += ".glif" + + # Update to contents.plist, filen and dtree + self.layer.contents.remove(oname) + self.layer.contents.addval(value, "string", glifn) + self.layer.dtree.removedfiles[self.filen] = glifn # Track so original glif does not get reported as invalid + self.filen = glifn + self.layer.dtree[glifn] = UT.dirTreeItem(read=False, added=True, fileObject=self, fileType="xml") + super(Uglif, self).__setattr__(name, value) + + def process_etree(self): + et = self.etree + self.name = getattrib(et, "name") + self.format = getattrib(et, "format") + if self.format is None: + if self.layer.font.UFOversion == "3": + self.format = '2' + else: + self.format = '1' + for i in range(len(et)): + element = et[i] + tag = element.tag + if not tag in self.glifElemOrder: self.layer.font.logger.log( + "Invalid element " + tag + " in glif " + self.name, "E") + if tag in _glifElemF1 or self.format == '2': + if tag in _glifElemMulti: + self._contents[tag].append(self.makeObject(tag, element)) + else: + self._contents[tag] = self.makeObject(tag, element) + + # Convert UFO2 style anchors to UFO3 anchors + if self._contents['outline'] is not None and self.format == "1": + for contour in self._contents['outline'].contours[:]: + if contour.UFO2anchor: + del contour.UFO2anchor["type"] # remove type="move" + self.add('anchor', contour.UFO2anchor) + self._contents['outline'].removeobject(contour, "contour") + if self._contents['outline'] is None: self.add('outline') + + self.format = "2" + + def rebuildET(self): + self.etree = ET.Element("glyph") + et = self.etree + et.attrib["name"] = self.name + et.attrib["format"] = self.format + # Insert sub-elements + for elem in self.glifElemOrder: + if elem in _glifElemF1 or self.format == "2": # Check element is valid for glif format + item = self._contents[elem] + if item is not None: + if elem in _glifElemMulti: + for object in item: + et.append(object.element) + else: + et.append(item.element) + + def add(self, ename, attrib=None): + # Add an element and corresponding object to a glif + element = ET.Element(ename) + if attrib: element.attrib = attrib + if ename == "lib": ET.SubElement(element, "dict") + multi = True if ename in _glifElemMulti else False + + if multi and ename not in self._contents: + self._contents[ename] = [] + + # Check element does not already exist for single elements + if ename in self._contents and not multi: + if self._contents[ename] is not None: self.layer.font.logger.log("Already an " + ename + " in glif", "X") + + # Add new object + if multi: + self._contents[ename].append(self.makeObject(ename, element)) + else: + self._contents[ename] = self.makeObject(ename, element) + + def remove(self, ename, index=None, object=None): + # Remove object from a glif + # For multi objects, an index or object must be supplied to identify which + # to delete + if ename in _glifElemMulti: + item = self._contents[ename] + if index is None: index = item.index(object) + del item[index] + else: + self._contents[ename] = None + + def convertToFormat1(self): + # Convert to a glif format of 1 (for UFO2) prior to writing out + self.format = "1" + # Change anchors to UFO2 style anchors. Sort anchors by anchor name first + anchororder = sorted(self._contents['anchor'], key=lambda x: x.element.attrib['name']) + for anchor in anchororder: + element = anchor.element + for attrn in ('colour', 'indentifier'): # Remove format 2 attributes + if attrn in element.attrib: del element.attrib[attrn] + element.attrib['type'] = 'move' + contelement = ET.Element("contour") + contelement.append(ET.Element("point", element.attrib)) + self._contents['outline'].appendobject(Ucontour(self._contents['outline'], contelement), "contour") + self.remove('anchor', object=anchor) + + def makeObject(self, type, element): + if type == 'advance': return Uadvance(self, element) + if type == 'unicode': return Uunicode(self, element) + if type == 'outline': return Uoutline(self, element) + if type == 'lib': return Ulib(self, element) + if type == 'note': return Unote(self, element) + if type == 'image': return Uimage(self, element) + if type == 'guideline': return Uguideline(self, element) + if type == 'anchor': return Uanchor(self, element) + + +class Uadvance(Uelement): + def __init__(self, glif, element): + super(Uadvance, self).__init__(element) + self.glif = glif + if 'width' in element.attrib: + self.width = element.attrib[str('width')] + else: + self.width = None + if 'height' in element.attrib: + self.height = element.attrib[str('height')] + else: + self.height = None + + def __setattr__(self, name, value): + if name in ('width', 'height'): + if value == "0" : value = None + if value is None: + if name in self.element.attrib: del self.element.attrib[name] + else: + value = str(value) + self.element.attrib[name] = value + super(Uadvance, self).__setattr__(name, value) + +class Uunicode(Uelement): + def __init__(self, glif, element): + super(Uunicode, self).__init__(element) + self.glif = glif + if 'hex' in element.attrib: + self.hex = element.attrib['hex'] + else: + self.hex = "" + self.glif.logger.log("No unicode hex attribute for " + glif.name, "E") + + def __setattr__(self, name, value): + if name == "hex": self.element.attrib['hex'] = value + super(Uunicode, self).__setattr__(name, value) + + +class Unote(Uelement): + def __init__(self, glif, element): + self.glif = glif + super(Unote, self).__init__(element) + + +class Uimage(Uelement): + def __init__(self, glif, element): + self.glif = glif + super(Uimage, self).__init__(element) + + +class Uguideline(Uelement): + def __init__(self, glif, element): + self.glif = glif + super(Uguideline, self).__init__(element) + + +class Uanchor(Uelement): + def __init__(self, glif, element): + self.glif = glif + super(Uanchor, self).__init__(element) + + +class Uoutline(Uelement): + def __init__(self, glif, element): + super(Uoutline, self).__init__(element) + self.glif = glif + self.components = [] + self.contours = [] + for tag in self._contents: + if tag == "component": + for component in self._contents[tag]: + self.components.append(Ucomponent(self, component)) + if tag == "contour": + for contour in self._contents[tag]: + self.contours.append(Ucontour(self, contour)) + + def removeobject(self, obj, typ): + super(Uoutline, self).remove(obj.element) + if typ == "component": self.components.remove(obj) + if typ == "contour": self.contours.remove(obj) + + def replaceobject(self, oldobj, newobj, typ): + eindex = list(self.element).index(oldobj.element) + super(Uoutline, self).replace(eindex, newobj.element) + if typ == "component": + cindex = self.components.index(oldobj) + self.components[cindex]= newobj + if typ == "contour": + cindex = self.contours.index(oldobj) + self.contours[cindex]= newobj + + def appendobject(self, item, typ): # Item can be an contour/component object, element or attribute list + if isinstance(item, (Ucontour, Ucomponent)): + obj = item + else: + if isinstance(item, dict): + elem = ET.Element(typ) + elem.attrib = item + elif isinstance(item, ET.Element): + elem = item + else: + self.glif.logger.log("item should be dict, element, Ucontour or Ucomponent", "S") + if typ == 'component': + obj = Ucomponent(self,elem) + else: + obj = Ucontour(self,elem) + super(Uoutline, self).append(obj.element) + if typ == "component": self.components.append(obj) + if typ == "contour": self.contours.append(obj) + + def insertobject(self, index, item, typ): # Needs updating to match appendobject + self.glif.logger.log("insertobject currently buggy so don't use!", "X") + # Bug is that index for super... should be different than components/contours. + # need to think through logic to sort this out... + # May need to take some logic from appendobject and some from replaceobj + + #super(Uoutline, self).insert(index, obj.element) + #if typ == "component": self.components.insert(index, obj) + #if typ == "contour": self.contours.insert(index, obj) + + +class Ucomponent(Uelement): + def __init__(self, outline, element): + super(Ucomponent, self).__init__(element) + self.outline = outline + + +class Ucontour(Uelement): + def __init__(self, outline, element): + super(Ucontour, self).__init__(element) + self.outline = outline + self.UFO2anchor = None + points = self._contents['point'] + # Identify UFO2-style anchor points + if len(points) == 1 and "type" in points[0].attrib: + if points[0].attrib["type"] == "move": + if "name" in points[0].attrib: + self.UFO2anchor = points[0].attrib + else: + self.outline.glif.layer.font.logger.log( + "Glyph " + self.outline.glif.name + " contains a single-point contour with no anchor name", "E") + + +class Ulib(_Ucontainer, _plist): + # For glif lib elements; top-level lib files use Uplist + def __init__(self, glif, element): + self.glif = glif + self.element = element # needs both element and etree for compatibility + self.etree = element # with other glif components and _plist methods + self._contents = {} + self.reindex() + + def reindex(self): + self._contents.clear() # Clear existing contents, if any + pl = self.element[0] + if pl.tag == "dict": + for i in range(0, len(pl), 2): + key = pl[i].text + self._contents[key] = [pl[i], pl[i + 1]] # The two elements for the item + + +class UfeatureFile(UtextFile): + def __init__(self, font, dirn, filen): + super(UfeatureFile, self).__init__(font, dirn, filen) + + +def writeXMLobject(dtreeitem, params, dirn, filen, exists, fobject=False): + object = dtreeitem if fobject else dtreeitem.fileObject # Set fobject to True if a file object is passed ratehr than dtreeitem + if object.outparams: params = object.outparams # override default params with object-specific ones + indentFirst = params["indentFirst"] + attribOrder = {} + if object.type in params['attribOrders']: attribOrder = params['attribOrders'][object.type] + if object.type == "plist": + indentFirst = params["plistIndentFirst"] + object.etree.attrib[".doctype"] = 'plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"' + + # Format ET data if any data parameters are set + if params["sortDicts"] or params["precision"] is not None: normETdata(object.etree, params, type=object.type) + + etw = ETU.ETWriter(object.etree, attributeOrder=attribOrder, indentIncr=params["indentIncr"], + indentFirst=indentFirst, indentML=params["indentML"], precision=params["precision"], + floatAttribs=params["floatAttribs"], intAttribs=params["intAttribs"]) + object.outxmlstr=etw.serialize_xml() + # Now we have the output xml, need to compare with existing item's xml, if present + changed = True + + if exists: # File already on disk + if exists == "same": # Output and input locations the same + oxmlstr = object.inxmlstr + else: # Read existing XML from disk + oxmlstr = "" + try: + oxml = io.open(os.path.join(dirn, filen), "r", encoding="utf-8") + except Exception as e: + print(e) + sys.exit(1) + for line in oxml.readlines(): + oxmlstr += line + oxml.close() + with warnings.catch_warnings(): + warnings.simplefilter("ignore", UnicodeWarning) + if oxmlstr == object.outxmlstr: changed = False + + if changed: object.write_to_file(dirn, filen) + if not fobject: dtreeitem.written = True # Mark as True, even if not changed - the file should still be there! + return changed # Boolean to indicate file updated on disk + + +def setFileForOutput(dtree, filen, fileObject, fileType): # Put details in dtree, creating item if needed + if filen not in dtree: + dtree[filen] = UT.dirTreeItem() + dtree[filen].added = True + dtree[filen].setinfo(fileObject=fileObject, fileType=fileType, towrite=True) + + +def writeToDisk(dtree, outdir, font, odtree=None, logindent="", changes = False): + if odtree is None: odtree = {} + # Make lists of items in dtree and odtree with type prepended for sorting and comparison purposes + dtreelist = [] + for filen in dtree: dtreelist.append(dtree[filen].type + filen) + dtreelist.sort() + odtreelist = [] + if odtree == {}: + locationtype = "Empty" + else: + if outdir == font.ufodir: + locationtype = "Same" + else: + locationtype = "Different" + for filen in odtree: odtreelist.append(odtree[filen].type + filen) + odtreelist.sort() + + okey = odtreelist.pop(0) if odtreelist != [] else None + + for key in dtreelist: + type = key[0:1] + filen = key[1:] + dtreeitem = dtree[filen] + + while okey and okey < key: # Item in output UFO no longer needed + ofilen = okey[1:] + if okey[0:1] == "f": + logmess = 'Deleting ' + ofilen + ' from existing output UFO' + os.remove(os.path.join(outdir, ofilen)) + else: + logmess = 'Deleting directory ' + ofilen + ' from existing output UFO' + shutil.rmtree(os.path.join(outdir, ofilen)) + if ofilen not in dtree.removedfiles: font.logger.log(logmess, "W") # No need to log for renamed files + okey = odtreelist.pop(0) if odtreelist != [] else None + + if key == okey: + exists = locationtype + okey = odtreelist.pop(0) if odtreelist != [] else None # Ready for next loop + else: + exists = False + + if dtreeitem.type == "f": + if dtreeitem.towrite: + font.logger.log(logindent + filen, "V") + if dtreeitem.fileType == "xml": + if dtreeitem.fileObject: # Only write if object has items + if dtreeitem.fileObject.type == "glif": + glif = dtreeitem.fileObject + if glif["lib"] is not None: # Delete lib if no items in it + if glif["lib"].__len__() == 0: + glif.remove("lib") + # Sort UFO3 anchors by name (UFO2 anchors will have been sorted on conversion) + glif["anchor"].sort(key=lambda anchor: anchor.element.get("name")) + glif.rebuildET() + result = writeXMLobject(dtreeitem, font.outparams, outdir, filen, exists) + if result: changes = True + else: # Delete existing item if the current object is empty + if exists: + font.logger.log('Deleting empty item ' + filen + ' from existing output UFO', "I") + os.remove(os.path.join(outdir, filen)) + changes = True + elif dtreeitem.fileType == "text": + dtreeitem.fileObject.write(dtreeitem, outdir, filen, exists) + ## Need to add code for other file types + else: + if filen in dtree.removedfiles: + if exists: + os.remove(os.path.join(outdir, filen)) # Silently remove old file for renamed files + changes = True + exists = False + else: # File should not have been in original UFO + if exists == "same": + font.logger.log('Deleting ' + filen + ' from existing UFO', "W") + os.remove(os.path.join(outdir, filen)) + changes = True + exists = False + else: + if not dtreeitem.added: + font.logger.log('Skipping invalid file ' + filen + ' from input UFO', "W") + if exists: + font.logger.log('Deleting ' + filen + ' from existing output UFO', "W") + os.remove(os.path.join(outdir, filen)) + changes = True + + else: # Must be directory + if not dtreeitem.read: + font.logger.log(logindent + "Skipping invalid input directory " + filen, "W") + if exists: + font.logger.log('Deleting directory ' + filen + ' from existing output UFO', "W") + shutil.rmtree(os.path.join(outdir, filen)) + changes = True + continue + font.logger.log(logindent + "Processing " + filen + " directory", "I") + subdir = os.path.join(outdir, filen) + if isinstance(dtreeitem.fileObject, Udirectory): + dtreeitem.fileObject.write(dtreeitem, outdir) + else: + if not os.path.exists(subdir): # If outdir does not exist, create it + try: + os.mkdir(subdir) + except Exception as e: + print(e) + sys.exit(1) + changes = True + + if exists: + subodtree = odtree[filen].dirtree + else: + subodtree = {} + subindent = logindent + " " + changes = writeToDisk(dtreeitem.dirtree, subdir, font, subodtree, subindent, changes) + if os.listdir(subdir) == []: + os.rmdir(subdir) # Delete directory if empty + changes = True + + while okey: # Any remaining items in odree list are no longer needed + ofilen = okey[1:] + if okey[0:1] == "f": + logmess = 'Deleting ' + ofilen + ' from existing output UFO' + os.remove(os.path.join(outdir, ofilen)) + changes = True + else: + logmess = 'Deleting directory ' + ofilen + ' from existing output UFO', "W" + shutil.rmtree(os.path.join(outdir, ofilen)) + changes = True + if ofilen not in dtree.removedfiles: font.logger.log(logmess, "W") # No need to log warning for removed files + okey = odtreelist.pop(0) if odtreelist != [] else None + return changes + +def normETdata(element, params, type): + # Recursively normalise the data an an ElementTree element + for subelem in element: + normETdata(subelem, params, type) + + precision = params["precision"] + if precision is not None: + if element.tag in ("integer", "real"): + num = round(float(element.text), precision) + if num == int(num): + element.tag = "integer" + element.text = "{}".format(int(num)) + else: + element.tag = "real" + element.text = "{}".format(num) + + if params["sortDicts"] and element.tag == "dict": + edict = {} + elist = [] + for i in range(0, len(element), 2): + edict[element[i].text] = [element[i], element[i + 1]] + elist.append(element[i].text) + keylist = sorted(edict.keys()) + if elist != keylist: + i = 0 + for key in keylist: + element[i] = edict[key][0] + element[i + 1] = edict[key][1] + i = i + 2 + + +def getattrib(element, attrib): return element.attrib[attrib] if attrib in element.attrib else None + + +def makeFileName(name, namelist=None): + if namelist is None: namelist = [] + # Replace illegal characters and add _ after UC letters + newname = "" + for x in name: + if x in _illegalChars: + x = "_" + else: + if x != x.lower(): x += "_" + newname += x + # Replace initial . if present + if newname[0] == ".": newname = "_" + newname[1:] + parts = [] + for part in newname.split("."): + if part in _reservedNames: + part = "_" + part + parts.append(part) + name = ".".join(parts) + if name.lower() in namelist: # case-insensitive name already used, so add a suffix + newname = None + i = 1 + while newname is None: + test = name + '{0:015d}'.format(i) + if not (test.lower() in namelist): newname = test + i += 1 + name = newname + return name diff --git a/src/silfont/util.py b/src/silfont/util.py new file mode 100644 index 0000000..bd9d5ee --- /dev/null +++ b/src/silfont/util.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +'General classes and functions for use in pysilfont scripts' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2014 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +import os, subprocess, difflib, sys, io, json +from silfont.core import execute +from pkg_resources import resource_filename +from csv import reader as csvreader + +try: + from fontTools.ttLib import TTFont +except Exception as e: + TTFont = None + +class dirTree(dict) : + """ An object to hold list of all files and directories in a directory + with option to read sub-directory contents into dirTree objects. + Iterates through readSub levels of subfolders + Flags to keep track of changes to files etc""" + def __init__(self,dirn,readSub = 9999) : + self.removedfiles = {} # List of files that have been renamed or deleted since reading from disk + for name in os.listdir(dirn) : + if name[-1:] == "~" : continue + item=dirTreeItem() + if os.path.isdir(os.path.join(dirn, name)) : + item.type = "d" + if readSub : + item.dirtree = dirTree(os.path.join(dirn,name),readSub-1) + self[name] = item + + def subTree(self,path) : # Returns dirTree object for a subtree based on subfolder name(s) + # 'path' can be supplied as either a relative path (eg "subf/subsubf") or array (eg ['subf','subsubf'] + if type(path) in (bytes, str): path = self._split(path) + subf=path[0] + if subf in self: + dtree = self[subf].dirtree + else : return None + + if len(path) == 1 : + return dtree + else : + path.pop(0) + return dtree.subTree(path) + + def _split(self,path) : # Turn a relative path into an array of subfolders + npath = [os.path.split(path)[1]] + while os.path.split(path)[0] : + path = os.path.split(path)[0] + npath.insert(0,os.path.split(path)[1]) + return npath + +class dirTreeItem(object) : + + def __init__(self, type = "f", dirtree = None, read = False, added = False, changed = False, towrite = False, written = False, fileObject = None, fileType = None, flags = {}) : + self.type = type # "d" or "f" + self.dirtree = dirtree # dirtree for a sub-directory + # Remaining properties are for calling scripts to use as they choose to track actions etc + self.read = read # Item has been read by the script + self.added = added # Item has been added to dirtree, so does not exist on disk + self.changed = changed # Item has been changed, so may need updating on disk + self.towrite = towrite # Item should be written out to disk + self.written = written # Item has been written to disk + self.fileObject = fileObject # An object representing the file + self.fileType = fileType # The type of the file object + self.flags = {} # Any other flags a script might need + + def setinfo(self, read = None, added = None, changed = None, towrite = None, written = None, fileObject = None, fileType = None, flags = None) : + pass + if read : self.read = read + if added : self.added = added + if changed : self.changed = changed + if towrite: self.towrite = towrite + if written : self.written = written + if fileObject is not None : self.fileObject = fileObject + if fileType : self.fileType = fileType + if flags : self.flags = flags + +class ufo_diff(object): # For diffing 2 ufos as part of testing + # returncodes: + # 0 - ufos are the same + # 1 - Differences were found + # 2 - Errors running the difference (eg can't open file) + # diff - text of the differences + # errors - text of the errors + + def __init__(self, ufo1, ufo2, ignoreOHCtime=True): + + diffcommand = ["diff", "-r", "-c1", ufo1, ufo2] + + # By default, if only difference in fontinfo is the openTypeHeadCreated timestamp ignore that + + if ignoreOHCtime: # Exclude fontinfo if only diff is openTypeHeadCreated + # Otherwise leave it in so differences are reported by main diff + fi1 = os.path.join(ufo1,"fontinfo.plist") + fi2 = os.path.join(ufo2, "fontinfo.plist") + fitest = subprocess.Popen(["diff", fi1, fi2, "-c1"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + text = fitest.communicate() + if fitest.returncode == 1: + difftext = text[0].decode("utf-8").split("\n") + if difftext[4].strip() == "<key>openTypeHeadCreated</key>" and len(difftext) == 12: + diffcommand.append("--exclude=fontinfo.plist") + + # Now do the main diff + test = subprocess.Popen(diffcommand, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + text = test.communicate() + self.returncode = test.returncode + self.diff = text[0].decode("utf-8") + self.errors = text[1] + + def print_text(self): # Print diff info or errors from the diffcommand + if self.returncode == 0: + print("UFOs are the same") + elif self.returncode == 1: + print("UFOs are different") + print(self.diff) + elif self.returncode == 2: + print("Failed to compare UFOs") + print(self.errors) + +class text_diff(object): # For diffing 2 text files with option to ignore common timestamps + # See ufo_diff for class attribute details + + def __init__(self, file1, file2, ignore_chars=0, ignore_firstlinechars = 0): + # ignore_chars - characters to ignore from left of each line; typically 20 for timestamps + # ignore_firstlinechars - as above, but just for first line, eg for initial comment in csv files, typically 22 + errors = [] + try: + f1 = [x[ignore_chars:-1].replace('\\','/') for x in io.open(file1, "r", encoding="utf-8").readlines()] + except IOError: + errors.append("Can't open " + file1) + try: + f2 = [x[ignore_chars:-1].replace('\\','/') for x in io.open(file2, "r", encoding="utf-8").readlines()] + except IOError: + errors.append("Can't open " + file2) + if errors == []: # Indicates both files were opened OK + if ignore_firstlinechars: # Ignore first line for files with first line comment with timestamp + f1[0] = f1[0][ignore_firstlinechars:-1] + f2[0] = f2[0][ignore_firstlinechars:-1] + self.errors = "" + self.diff = "\n".join([x for x in difflib.unified_diff(f1, f2, file1, file2, n=0)]) + self.returncode = 0 if self.diff == "" else 1 + else: + self.diff = "" + self.errors = "\n".join(errors) + self.returncode = 2 + + def print_text(self): # Print diff info or errors the unified_diff command + if self.returncode == 0: + print("Files are the same") + elif self.returncode == 1: + print("Files are different") + print(self.diff) + elif self.returncode == 2: + print("Failed to compare Files") + print(self.errors) + +class ttf_diff(object): # For diffing 2 ttf files. Differences are not listed + # See ufo_diff for class attribute details + + def __init__(self, file1, file2): + errors=[] + if TTFont is None: + self.diff="" + self.errors="Testing failed - class ttf_diff requires fontTools to be installed" + self.returncode = 2 + return + + # Open the ttf files + try: + font1 = TTFont(file1) + except Exception as e: + errors.append("Can't open " + file1) + errors.append(e.__str__()) + try: + font2 = TTFont(file2) + except Exception as e: + errors.append("Can't open " + file2) + errors.append(e.__str__()) + if errors: + self.diff = "" + self.errors = "\n".join(errors) + self.returncode = 2 + return + + # Create ttx xml strings from each font + ttx1 = _ttx() + ttx2 = _ttx() + font1.saveXML(ttx1) + font2.saveXML(ttx2) + + if ttx1.txt() == ttx2.txt(): + self.diff = "" + self.errors = "" + self.returncode = 0 + else: + self.diff = file1 + " and " + file2 + " are different - compare with external tools" + self.errors = "" + self.returncode = 1 + + def print_text(self): # Print diff info or errors the unified_diff command + if self.returncode == 0: + print("Files are the same") + elif self.returncode == 1: + print("Files are different") + print(self.diff) + elif self.returncode == 2: + print("Failed to compare Files") + print(self.errors) + +def test_run(tool, commandline, testcommand, outfont, exp_errors, exp_warnings): # Used by tests to run commands + sys.argv = commandline.split(" ") + (args, font) = execute(tool, testcommand.doit, testcommand.argspec, chain="first") + if outfont: + if tool in ("FT", "FP"): + font.save(outfont) + else: # Must be Pyslifont Ufont + font.write(outfont) + args.logger.logfile.close() # Need to close the log so that the diff test can be run + exp_counts = (exp_errors, exp_warnings) + actual_counts = (args.logger.errorcount, args.logger.warningcount) + result = exp_counts == actual_counts + if not result: print("Mis-match of logger errors/warnings: " + str(exp_counts) + " vs " + str(actual_counts)) + return result + +def test_diffs(dirname, testname, extensions): # Used by test to run diffs on results files based on extensions + result = True + for ext in extensions: + resultfile = os.path.join("local/testresults", dirname, testname + ext) + referencefile = os.path.join("tests/reference", dirname, testname + ext) + if ext == ".ufo": + diff = ufo_diff(resultfile, referencefile) + elif ext == ".csv": + diff = text_diff(resultfile, referencefile, ignore_firstlinechars=22) + elif ext in (".log", ".lg"): + diff = text_diff(resultfile, referencefile, ignore_chars=20) + elif ext == ".ttf": + diff = ttf_diff(resultfile, referencefile) + else: + diff = text_diff(resultfile, referencefile) + + if diff.returncode: + diff.print_text() + result = False + return result + +class _ttx(object): # Used by ttf_diff() + + def __init__(self): + self.lines = [] + + def write(self, line): + if not("<checkSumAdjustment value=" in line or "<modified value=" in line) : + self.lines.append(line) + + def txt(self): + return "".join(self.lines) + +# Functions for mapping color def to names based on the colors provided by app UIs +namestocolorslist = { + 'g_red': '0.85,0.26,0.06,1', # g_ names refers to colors definable using the Glyphs UI + 'g_orange': '0.99,0.62,0.11,1', + 'g_brown': '0.65,0.48,0.2,1', + 'g_yellow': '0.97,1,0,1', + 'g_light_green': '0.67,0.95,0.38,1', + 'g_dark_green': '0.04,0.57,0.04,1', + 'g_cyan': '0,0.67,0.91,1', + 'g_blue': '0.18,0.16,0.78,1', + 'g_purple': '0.5,0.09,0.79,1', + 'g_pink': '0.98,0.36,0.67,1', + 'g_light_gray': '0.75,0.75,0.75,1', + 'g_dark_gray': '0.25,0.25,0.25,1' +} +colorstonameslist = {v: k for k, v in namestocolorslist.items()} + +def nametocolor(color, default=None): + global namestocolorslist + if default is not None: + return namestocolorslist.get(color,default) + else: + return namestocolorslist.get(color) + +def colortoname(color, default=None): + global colorstonameslist + if default: + return colorstonameslist.get(color,default) + else: + return colorstonameslist.get(color) + +def parsecolors(colors, single = False, allowspecial = False): # Process a list of colors - designed for handling command-line input + # Colors can be in RBGA format (eg (0.25,0.25,0.25,1)) or text name (eg g_dark_grey), separated by commas. + # Function returns a list of tuples, one per color, (RGBA, name, logcolor, original color after splitting) + # If the color can't be parsed, RGBA will be None and logocolor contain an error message + # If single is set, just return one tuple rather than a list of tuples + # Also can allow for special values of 'none' and 'leave' if allowspecial set + + # First tidy up the input string + cols = colors.lower().replace(" ", "") + + if single: # If just one color, don't need to split the string and can allow for RGBA without brackets + splitcols = ["(" + cols + ")"] if cols[0] in ("0", "1") else [cols] + else: + # Since RGBA colors which have parentheses and then commas within them, so can't just split on commas so add @ signs for first split + cols = cols.replace(",(", "@(").replace("),", ")@").split("@") + splitcols = [] + for color in cols: + if color[0] == "(": + splitcols.append(color) + else: + splitcols = splitcols + color.split(',') + parsed = [] + for splitcol in splitcols: + if allowspecial and splitcol in ("none", "leave"): + RGBA = "" + name = splitcol + logcolor = splitcol + else: + errormess = "" + name = "" + RGBA = "" + if splitcol[0] == '(': + values = splitcol[1:-1].split(',') # Remove parentheses then split on commas + if len(values) != 4: + errormess = "RGBA colours must have 4 values" + else: + for i in (0, 1, 2, 3): + values[i] = float(values[i]) + if values[i] < 0 or values[i] > 1: errormess = "RGBA values must be between 0 and 1" + if values[0] + values[1] + values[2] == 0: errormess = "At lease one RGB value must be non-zero" + if values[3] == 0: errormess = "With RGBA, A must not be zero" + if errormess == "": + for i in (0, 1, 2, 3): + v = values[i] + if v == int(v): v = int(v) # Convert integers to int type for correct formatting with str() + RGBA += str(v) + "," + RGBA = RGBA[0:-1] # Strip trialing comma + name = colortoname(RGBA, "") + else: + name = splitcol + RGBA = nametocolor(name) + if RGBA is None: errormess = "Invalid color name" + if errormess: + logcolor = "Invalid color: " + splitcol + " - " + errormess + RGBA = None + name = None + else: + logcolor = RGBA + if name: logcolor += " (" + name + ")" + parsed.append((RGBA, name, logcolor,splitcol)) + if single: parsed = parsed[0] + + return parsed + +# Provide dict of required characters which match the supplied list of sets - sets can be basic, rtl or sil +def required_chars(sets="basic"): + if type(sets) == str: sets = (sets,) # Convert single string to a tuple + rcfile = open(resource_filename('silfont','data/required_chars.csv')) + rcreader = csvreader(rcfile) + next(rcreader) # Read fist line which is headers + rcdict = {} + for line in rcreader: + unicode = line[0][2:] + item = { + "ps_name": line[1], + "glyph_name": line[2], + "sil_set": line[3], + "rationale": line[4], + "notes": line[5] + } + if item["sil_set"] in sets: rcdict[unicode] = item + return rcdict + +# Pretty print routine for json files. +def prettyjson(data, oneliners=None, indent="", inkey=None, oneline=False): + # data - json data to format + # oneliners - lists of keys for which data should be output on a single line + # indent, inkey & oneline - used when prettyjson calls itself interactively for sub-values that are dicts + res = ["{"] + thisoneline = oneline and (oneliners is None or inkey not in oneliners) + for key, value in sorted(data.items()): + line = ("" if thisoneline else indent) + '"{}": '.format(key) + if isinstance(value, dict): + val = prettyjson(value, oneliners=oneliners, + indent = indent + " ", inkey = key, + oneline=(oneline if oneliners is None or key not in oneliners else True)) + else: + val = json.dumps(value, ensure_ascii=False) + res.append(line + val + ",") + res[-1] = res[-1][:-1] + res.append(("" if thisoneline else indent[:-4]) + "}") + return (" " if thisoneline else "\n").join(res) + |