From da875fcb62c801b8d19b3d4d984ad963574fb356 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Thu, 2 Mar 2023 21:01:10 +0100 Subject: Adding upstream version 1.6.0. Signed-off-by: Daniel Baumann --- lib/silfont/scripts/__init__.py | 0 lib/silfont/scripts/psfaddanchors.py | 73 ++++ lib/silfont/scripts/psfbuildcomp.py | 309 ++++++++++++++ lib/silfont/scripts/psfbuildcompgc.py | 45 ++ lib/silfont/scripts/psfbuildfea.py | 89 ++++ lib/silfont/scripts/psfchangegdlnames.py | 160 +++++++ lib/silfont/scripts/psfchangettfglyphnames.py | 35 ++ lib/silfont/scripts/psfcheckbasicchars.py | 68 +++ lib/silfont/scripts/psfcheckclassorders.py | 142 +++++++ lib/silfont/scripts/psfcheckftml.py | 65 +++ lib/silfont/scripts/psfcheckglyphinventory.py | 125 ++++++ lib/silfont/scripts/psfcheckinterpolatable.py | 75 ++++ lib/silfont/scripts/psfcompdef2xml.py | 65 +++ lib/silfont/scripts/psfcompressgr.py | 100 +++++ lib/silfont/scripts/psfcopyglyphs.py | 243 +++++++++++ lib/silfont/scripts/psfcopymeta.py | 148 +++++++ lib/silfont/scripts/psfcreateinstances.py | 228 ++++++++++ lib/silfont/scripts/psfcsv2comp.py | 129 ++++++ lib/silfont/scripts/psfdeflang.py | 47 +++ lib/silfont/scripts/psfdeleteglyphs.py | 144 +++++++ lib/silfont/scripts/psfdupglyphs.py | 48 +++ lib/silfont/scripts/psfexportanchors.py | 101 +++++ lib/silfont/scripts/psfexportmarkcolors.py | 55 +++ lib/silfont/scripts/psfexportpsnames.py | 47 +++ lib/silfont/scripts/psfexportunicodes.py | 43 ++ lib/silfont/scripts/psffixffglifs.py | 48 +++ lib/silfont/scripts/psffixfontlab.py | 169 ++++++++ lib/silfont/scripts/psfftml2TThtml.py | 389 +++++++++++++++++ lib/silfont/scripts/psfftml2odt.py | 453 ++++++++++++++++++++ lib/silfont/scripts/psfgetglyphnames.py | 87 ++++ lib/silfont/scripts/psfglyphs2ufo.py | 275 ++++++++++++ lib/silfont/scripts/psfmakedeprecated.py | 74 ++++ lib/silfont/scripts/psfmakefea.py | 369 ++++++++++++++++ lib/silfont/scripts/psfmakescaledshifted.py | 117 +++++ lib/silfont/scripts/psfmakewoffmetadata.py | 225 ++++++++++ lib/silfont/scripts/psfnormalize.py | 31 ++ lib/silfont/scripts/psfremovegliflibkeys.py | 71 ++++ lib/silfont/scripts/psfrenameglyphs.py | 587 ++++++++++++++++++++++++++ lib/silfont/scripts/psfrunfbchecks.py | 249 +++++++++++ lib/silfont/scripts/psfsetassocfeat.py | 54 +++ lib/silfont/scripts/psfsetassocuids.py | 51 +++ lib/silfont/scripts/psfsetdummydsig.py | 36 ++ lib/silfont/scripts/psfsetglyphdata.py | 143 +++++++ lib/silfont/scripts/psfsetglyphorder.py | 91 ++++ lib/silfont/scripts/psfsetkeys.py | 100 +++++ lib/silfont/scripts/psfsetmarkcolors.py | 106 +++++ lib/silfont/scripts/psfsetpsnames.py | 86 ++++ lib/silfont/scripts/psfsetunicodes.py | 79 ++++ lib/silfont/scripts/psfsetversion.py | 85 ++++ lib/silfont/scripts/psfshownames.py | 235 +++++++++++ lib/silfont/scripts/psfsubset.py | 112 +++++ lib/silfont/scripts/psfsyncmasters.py | 293 +++++++++++++ lib/silfont/scripts/psfsyncmeta.py | 303 +++++++++++++ lib/silfont/scripts/psftuneraliases.py | 121 ++++++ lib/silfont/scripts/psfufo2glyphs.py | 69 +++ lib/silfont/scripts/psfufo2ttf.py | 108 +++++ lib/silfont/scripts/psfversion.py | 64 +++ lib/silfont/scripts/psfwoffit.py | 99 +++++ lib/silfont/scripts/psfxml2compdef.py | 33 ++ 59 files changed, 7996 insertions(+) create mode 100755 lib/silfont/scripts/__init__.py create mode 100755 lib/silfont/scripts/psfaddanchors.py create mode 100755 lib/silfont/scripts/psfbuildcomp.py create mode 100644 lib/silfont/scripts/psfbuildcompgc.py create mode 100755 lib/silfont/scripts/psfbuildfea.py create mode 100755 lib/silfont/scripts/psfchangegdlnames.py create mode 100644 lib/silfont/scripts/psfchangettfglyphnames.py create mode 100644 lib/silfont/scripts/psfcheckbasicchars.py create mode 100644 lib/silfont/scripts/psfcheckclassorders.py create mode 100644 lib/silfont/scripts/psfcheckftml.py create mode 100644 lib/silfont/scripts/psfcheckglyphinventory.py create mode 100644 lib/silfont/scripts/psfcheckinterpolatable.py create mode 100755 lib/silfont/scripts/psfcompdef2xml.py create mode 100755 lib/silfont/scripts/psfcompressgr.py create mode 100644 lib/silfont/scripts/psfcopyglyphs.py create mode 100755 lib/silfont/scripts/psfcopymeta.py create mode 100644 lib/silfont/scripts/psfcreateinstances.py create mode 100644 lib/silfont/scripts/psfcsv2comp.py create mode 100755 lib/silfont/scripts/psfdeflang.py create mode 100644 lib/silfont/scripts/psfdeleteglyphs.py create mode 100644 lib/silfont/scripts/psfdupglyphs.py create mode 100755 lib/silfont/scripts/psfexportanchors.py create mode 100755 lib/silfont/scripts/psfexportmarkcolors.py create mode 100755 lib/silfont/scripts/psfexportpsnames.py create mode 100755 lib/silfont/scripts/psfexportunicodes.py create mode 100755 lib/silfont/scripts/psffixffglifs.py create mode 100755 lib/silfont/scripts/psffixfontlab.py create mode 100755 lib/silfont/scripts/psfftml2TThtml.py create mode 100755 lib/silfont/scripts/psfftml2odt.py create mode 100755 lib/silfont/scripts/psfgetglyphnames.py create mode 100644 lib/silfont/scripts/psfglyphs2ufo.py create mode 100644 lib/silfont/scripts/psfmakedeprecated.py create mode 100755 lib/silfont/scripts/psfmakefea.py create mode 100644 lib/silfont/scripts/psfmakescaledshifted.py create mode 100755 lib/silfont/scripts/psfmakewoffmetadata.py create mode 100755 lib/silfont/scripts/psfnormalize.py create mode 100644 lib/silfont/scripts/psfremovegliflibkeys.py create mode 100644 lib/silfont/scripts/psfrenameglyphs.py create mode 100755 lib/silfont/scripts/psfrunfbchecks.py create mode 100755 lib/silfont/scripts/psfsetassocfeat.py create mode 100755 lib/silfont/scripts/psfsetassocuids.py create mode 100644 lib/silfont/scripts/psfsetdummydsig.py create mode 100644 lib/silfont/scripts/psfsetglyphdata.py create mode 100755 lib/silfont/scripts/psfsetglyphorder.py create mode 100755 lib/silfont/scripts/psfsetkeys.py create mode 100755 lib/silfont/scripts/psfsetmarkcolors.py create mode 100755 lib/silfont/scripts/psfsetpsnames.py create mode 100755 lib/silfont/scripts/psfsetunicodes.py create mode 100755 lib/silfont/scripts/psfsetversion.py create mode 100755 lib/silfont/scripts/psfshownames.py create mode 100644 lib/silfont/scripts/psfsubset.py create mode 100644 lib/silfont/scripts/psfsyncmasters.py create mode 100755 lib/silfont/scripts/psfsyncmeta.py create mode 100644 lib/silfont/scripts/psftuneraliases.py create mode 100644 lib/silfont/scripts/psfufo2glyphs.py create mode 100644 lib/silfont/scripts/psfufo2ttf.py create mode 100755 lib/silfont/scripts/psfversion.py create mode 100644 lib/silfont/scripts/psfwoffit.py create mode 100755 lib/silfont/scripts/psfxml2compdef.py (limited to 'lib/silfont/scripts') diff --git a/lib/silfont/scripts/__init__.py b/lib/silfont/scripts/__init__.py new file mode 100755 index 0000000..e69de29 diff --git a/lib/silfont/scripts/psfaddanchors.py b/lib/silfont/scripts/psfaddanchors.py new file mode 100755 index 0000000..88f595f --- /dev/null +++ b/lib/silfont/scripts/psfaddanchors.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python +__doc__ = 'read anchor data from XML file and apply to UFO' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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'},{}), + # '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] + 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/lib/silfont/scripts/psfbuildcomp.py b/lib/silfont/scripts/psfbuildcomp.py new file mode 100755 index 0000000..0c5d790 --- /dev/null +++ b/lib/silfont/scripts/psfbuildcomp.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python +__doc__ = '''Read Composite Definitions and add glyphs to a UFO font''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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 "(0.04,0.57,0.04,1)" + 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/lib/silfont/scripts/psfbuildcompgc.py b/lib/silfont/scripts/psfbuildcompgc.py new file mode 100644 index 0000000..e3d0a3e --- /dev/null +++ b/lib/silfont/scripts/psfbuildcompgc.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +'''Uses the GlyphConstruction library to build composite glyphs.''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfbuildfea.py b/lib/silfont/scripts/psfbuildfea.py new file mode 100755 index 0000000..41cf099 --- /dev/null +++ b/lib/silfont/scripts/psfbuildfea.py @@ -0,0 +1,89 @@ +#!/usr/bin/python3 +__doc__ = 'Build features.fea file into a ttf font' +# TODO: add conditional compilation, compare to fea, compile to ttf +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfchangegdlnames.py b/lib/silfont/scripts/psfchangegdlnames.py new file mode 100755 index 0000000..2a10869 --- /dev/null +++ b/lib/silfont/scripts/psfchangegdlnames.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python +__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__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2016 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfchangettfglyphnames.py b/lib/silfont/scripts/psfchangettfglyphnames.py new file mode 100644 index 0000000..2c9fa37 --- /dev/null +++ b/lib/silfont/scripts/psfchangettfglyphnames.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +__doc__ = 'Rename the glyphs in a ttf file based on production names in a UFO' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfcheckbasicchars.py b/lib/silfont/scripts/psfcheckbasicchars.py new file mode 100644 index 0000000..c86beac --- /dev/null +++ b/lib/silfont/scripts/psfcheckbasicchars.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python +__doc__ = '''Checks a UFO for the presence of glyphs that represent the +Recommended characters for Non-Roman fonts and warns if any are missing. +http://scriptsource.org/entry/gg5wm9hhd3''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfcheckclassorders.py b/lib/silfont/scripts/psfcheckclassorders.py new file mode 100644 index 0000000..12654c9 --- /dev/null +++ b/lib/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__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfcheckftml.py b/lib/silfont/scripts/psfcheckftml.py new file mode 100644 index 0000000..84df95f --- /dev/null +++ b/lib/silfont/scripts/psfcheckftml.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python +'''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 list ''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2021 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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 elements in a + 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/lib/silfont/scripts/psfcheckglyphinventory.py b/lib/silfont/scripts/psfcheckglyphinventory.py new file mode 100644 index 0000000..37a5ffc --- /dev/null +++ b/lib/silfont/scripts/psfcheckglyphinventory.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python +__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__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2020 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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 = {} + uidFromCSVglyph = {} + + # 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 + glyphList.add(gname) + + # Process USV + # could be empty string, a single USV or space-separated list of USVs + try: + uidList = [int(x, 16) for x in line[usvCol].split()] + except Exception as e: + csvWarning("invalid USV '%s' (%s); ignored: " % (line[usvCol], e.message)) + uidList = [] + if len(uidList) == 1: + # Handle simple encoded glyphs + uid = uidList[0] + if uid in glyphFromCSVuid: + csvWarning('USV %04X previously seen; ignored' % uid) + uidList = [] + else: + # Remember this glyph + glyphFromCSVuid[uid] = gname + uidFromCSVglyph[gname] = 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 cam check USV inventory of glyphs in common + inBoth = glyphList & ufoList # Glyphs we want to examine + csvEncodings = set(f'{gname}|{uidFromCSVglyph[gname]:04X}' for gname in filter(lambda x: x in uidFromCSVglyph, inBoth)) + ufoEncodings = set(f'{gname}|{int(x.hex, 16):04X}' for gname in inBoth for x 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/lib/silfont/scripts/psfcheckinterpolatable.py b/lib/silfont/scripts/psfcheckinterpolatable.py new file mode 100644 index 0000000..abcf526 --- /dev/null +++ b/lib/silfont/scripts/psfcheckinterpolatable.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +__doc__ = '''Check that the ufos in a designspace file are interpolatable''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2021 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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 ', "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/lib/silfont/scripts/psfcompdef2xml.py b/lib/silfont/scripts/psfcompdef2xml.py new file mode 100755 index 0000000..62f9972 --- /dev/null +++ b/lib/silfont/scripts/psfcompdef2xml.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python +__doc__ = 'convert composite definition file to XML format' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfcompressgr.py b/lib/silfont/scripts/psfcompressgr.py new file mode 100755 index 0000000..5802cce --- /dev/null +++ b/lib/silfont/scripts/psfcompressgr.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +__doc__ = 'Compress Graphite tables in a font' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfcopyglyphs.py b/lib/silfont/scripts/psfcopyglyphs.py new file mode 100644 index 0000000..fe37bf7 --- /dev/null +++ b/lib/silfont/scripts/psfcopyglyphs.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python +__doc__ = """Copy glyphs from one UFO to another""" +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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 exsting 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/lib/silfont/scripts/psfcopymeta.py b/lib/silfont/scripts/psfcopymeta.py new file mode 100755 index 0000000..1e031fd --- /dev/null +++ b/lib/silfont/scripts/psfcopymeta.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python +__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__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfcreateinstances.py b/lib/silfont/scripts/psfcreateinstances.py new file mode 100644 index 0000000..468435f --- /dev/null +++ b/lib/silfont/scripts/psfcreateinstances.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python +__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__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfcsv2comp.py b/lib/silfont/scripts/psfcsv2comp.py new file mode 100644 index 0000000..7a4a960 --- /dev/null +++ b/lib/silfont/scripts/psfcsv2comp.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python +__doc__ = '''generate composite definitions from csv file''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfdeflang.py b/lib/silfont/scripts/psfdeflang.py new file mode 100755 index 0000000..3a1ac26 --- /dev/null +++ b/lib/silfont/scripts/psfdeflang.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +__doc__ = '''Switch default language in a font''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfdeleteglyphs.py b/lib/silfont/scripts/psfdeleteglyphs.py new file mode 100644 index 0000000..94caa36 --- /dev/null +++ b/lib/silfont/scripts/psfdeleteglyphs.py @@ -0,0 +1,144 @@ +#!/usr/bin/env python +__doc__ = '''Deletes glyphs from a UFO based on list. Can instead delete glyphs not in list.''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfdupglyphs.py b/lib/silfont/scripts/psfdupglyphs.py new file mode 100644 index 0000000..1b68bee --- /dev/null +++ b/lib/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__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfexportanchors.py b/lib/silfont/scripts/psfexportanchors.py new file mode 100755 index 0000000..fd27a47 --- /dev/null +++ b/lib/silfont/scripts/psfexportanchors.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python +__doc__ = 'export anchor data from UFO to XML file' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015,2016 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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 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 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/lib/silfont/scripts/psfexportmarkcolors.py b/lib/silfont/scripts/psfexportmarkcolors.py new file mode 100755 index 0000000..98beae1 --- /dev/null +++ b/lib/silfont/scripts/psfexportmarkcolors.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python +__doc__ = '''Write mapping of glyph name to cell mark color to a csv file +- csv format glyphname,colordef''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfexportpsnames.py b/lib/silfont/scripts/psfexportpsnames.py new file mode 100755 index 0000000..5995e14 --- /dev/null +++ b/lib/silfont/scripts/psfexportpsnames.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +__doc__ = '''Write mapping of glyph name to postscript name to a csv file +- csv format glyphname,postscriptname''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2016 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfexportunicodes.py b/lib/silfont/scripts/psfexportunicodes.py new file mode 100755 index 0000000..c9e1be3 --- /dev/null +++ b/lib/silfont/scripts/psfexportunicodes.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python +__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__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2016-2020 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psffixffglifs.py b/lib/silfont/scripts/psffixffglifs.py new file mode 100755 index 0000000..f496737 --- /dev/null +++ b/lib/silfont/scripts/psffixffglifs.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python +__doc__ = '''Make changes needed to a UFO following processing by FontForge. +''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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 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/lib/silfont/scripts/psffixfontlab.py b/lib/silfont/scripts/psffixfontlab.py new file mode 100755 index 0000000..c83868f --- /dev/null +++ b/lib/silfont/scripts/psffixfontlab.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python +__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__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2021 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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", "openTypeHeadFlags", "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/lib/silfont/scripts/psfftml2TThtml.py b/lib/silfont/scripts/psfftml2TThtml.py new file mode 100755 index 0000000..e9e1128 --- /dev/null +++ b/lib/silfont/scripts/psfftml2TThtml.py @@ -0,0 +1,389 @@ +#! /usr/bin/python3 +'''Build fonts for all combinations of TypeTuner features needed for specific ftml then build html that uses those fonts''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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('''\ + + + +''') + # 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 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/lib/silfont/scripts/psfftml2odt.py b/lib/silfont/scripts/psfftml2odt.py new file mode 100755 index 0000000..49777e4 --- /dev/null +++ b/lib/silfont/scripts/psfftml2odt.py @@ -0,0 +1,453 @@ +#!/usr/bin/env python +__doc__ = 'read FTML file and generate LO writer .odt file' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015, SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfgetglyphnames.py b/lib/silfont/scripts/psfgetglyphnames.py new file mode 100755 index 0000000..da685ed --- /dev/null +++ b/lib/silfont/scripts/psfgetglyphnames.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python +__doc__ = '''Create a list of glyphs to import from a list of characters.''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019-2020 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfglyphs2ufo.py b/lib/silfont/scripts/psfglyphs2ufo.py new file mode 100644 index 0000000..3af2ddd --- /dev/null +++ b/lib/silfont/scripts/psfglyphs2ufo.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python +__doc__ = '''Export fonts in a GlyphsApp file to UFOs''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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 + 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) + + # Extract directory name for use with restores + (glyphsdir, filen) = os.path.split(args.glyphsfont) + + 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", "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, glyphsdir, 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, glyphsdir, 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 same directory as input .gylphs file + + ufodir = os.path.join(glyphsdir, 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 + + for layer in ufo.layers: + for glyph in layer: + lib = glyph.lib + if "public.verticalOrigin" in lib: del lib["public.verticalOrigin"] + + # Write ufo out + ufopath = os.path.join(args.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(args.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/lib/silfont/scripts/psfmakedeprecated.py b/lib/silfont/scripts/psfmakedeprecated.py new file mode 100644 index 0000000..b86ed4c --- /dev/null +++ b/lib/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__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfmakefea.py b/lib/silfont/scripts/psfmakefea.py new file mode 100755 index 0000000..6ef0613 --- /dev/null +++ b/lib/silfont/scripts/psfmakefea.py @@ -0,0 +1,369 @@ +#!/usr/bin/python3 +__doc__ = 'Make features.fea file' +# TODO: add conditional compilation, compare to fea, compile to ttf +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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 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/lib/silfont/scripts/psfmakescaledshifted.py b/lib/silfont/scripts/psfmakescaledshifted.py new file mode 100644 index 0000000..69bbbb9 --- /dev/null +++ b/lib/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__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfmakewoffmetadata.py b/lib/silfont/scripts/psfmakewoffmetadata.py new file mode 100755 index 0000000..c7d8128 --- /dev/null +++ b/lib/silfont/scripts/psfmakewoffmetadata.py @@ -0,0 +1,225 @@ +#!/usr/bin/env python +__doc__ = 'Make the WOFF metadata xml file based on input UFO (and optionally FONTLOG.txt)' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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="http://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/lib/silfont/scripts/psfnormalize.py b/lib/silfont/scripts/psfnormalize.py new file mode 100755 index 0000000..82b132e --- /dev/null +++ b/lib/silfont/scripts/psfnormalize.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +__doc__ = '''Normalize a UFO and optionally convert between UFO2 and UFO3''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfremovegliflibkeys.py b/lib/silfont/scripts/psfremovegliflibkeys.py new file mode 100644 index 0000000..5d59ad7 --- /dev/null +++ b/lib/silfont/scripts/psfremovegliflibkeys.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python +__doc__ = '''Remove the specified key(s) from glif libs''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfrenameglyphs.py b/lib/silfont/scripts/psfrenameglyphs.py new file mode 100644 index 0000000..ff8ef73 --- /dev/null +++ b/lib/silfont/scripts/psfrenameglyphs.py @@ -0,0 +1,587 @@ +#!/usr/bin/env python +__doc__ = '''Assign new working names to glyphs based on csv input file +- csv format oldname,newname''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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 + 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/lib/silfont/scripts/psfrunfbchecks.py b/lib/silfont/scripts/psfrunfbchecks.py new file mode 100755 index 0000000..eddf8bc --- /dev/null +++ b/lib/silfont/scripts/psfrunfbchecks.py @@ -0,0 +1,249 @@ +#!/usr/bin/env python +'''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__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2020 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +import glob, os, csv + +from textwrap import TextWrapper + +from fontbakery.reporters.serialize import SerializeReporter +from fontbakery.reporters.html import HTMLReporter +from fontbakery.checkrunner import distribute_generator, CheckRunner, get_module_profile, SKIP, INFO +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/lib/silfont/scripts/psfsetassocfeat.py b/lib/silfont/scripts/psfsetassocfeat.py new file mode 100755 index 0000000..da33bb4 --- /dev/null +++ b/lib/silfont/scripts/psfsetassocfeat.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python +__doc__ = '''Add associate feature info to glif lib based on a csv file +csv format glyphname,featurename[,featurevalue]''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfsetassocuids.py b/lib/silfont/scripts/psfsetassocuids.py new file mode 100755 index 0000000..e8be848 --- /dev/null +++ b/lib/silfont/scripts/psfsetassocuids.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python +__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__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfsetdummydsig.py b/lib/silfont/scripts/psfsetdummydsig.py new file mode 100644 index 0000000..02896a0 --- /dev/null +++ b/lib/silfont/scripts/psfsetdummydsig.py @@ -0,0 +1,36 @@ +#!/usr/bin/python3 + +__doc__ = 'Put a dummy DSIG table into a ttf font' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfsetglyphdata.py b/lib/silfont/scripts/psfsetglyphdata.py new file mode 100644 index 0000000..9aa2162 --- /dev/null +++ b/lib/silfont/scripts/psfsetglyphdata.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python +__doc__ = '''Update and/or sort glyph_data.csv based on input file(s)''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2021 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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 exisitng 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/lib/silfont/scripts/psfsetglyphorder.py b/lib/silfont/scripts/psfsetglyphorder.py new file mode 100755 index 0000000..e2448e7 --- /dev/null +++ b/lib/silfont/scripts/psfsetglyphorder.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +__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__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfsetkeys.py b/lib/silfont/scripts/psfsetkeys.py new file mode 100755 index 0000000..2cee4ab --- /dev/null +++ b/lib/silfont/scripts/psfsetkeys.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python +__doc__ = '''Set keys with given values in a UFO plist file.''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfsetmarkcolors.py b/lib/silfont/scripts/psfsetmarkcolors.py new file mode 100755 index 0000000..c436f4c --- /dev/null +++ b/lib/silfont/scripts/psfsetmarkcolors.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python +__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__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfsetpsnames.py b/lib/silfont/scripts/psfsetpsnames.py new file mode 100755 index 0000000..55528c7 --- /dev/null +++ b/lib/silfont/scripts/psfsetpsnames.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +__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__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfsetunicodes.py b/lib/silfont/scripts/psfsetunicodes.py new file mode 100755 index 0000000..aac1778 --- /dev/null +++ b/lib/silfont/scripts/psfsetunicodes.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python +__doc__ = '''Set the unicodes of glyphs in a font based on an external csv file. +- csv format glyphname,unicode, [unicode2, [,unicode3]]''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2016 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfsetversion.py b/lib/silfont/scripts/psfsetversion.py new file mode 100755 index 0000000..764271e --- /dev/null +++ b/lib/silfont/scripts/psfsetversion.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python +__doc__ = '''Update the various font version fields''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfshownames.py b/lib/silfont/scripts/psfshownames.py new file mode 100755 index 0000000..191e0f8 --- /dev/null +++ b/lib/silfont/scripts/psfshownames.py @@ -0,0 +1,235 @@ +#!/usr/bin/python3 +__doc__ = 'Display name fields and other bits for linking fonts into families' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2021 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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.fonts), "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 familes, 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/lib/silfont/scripts/psfsubset.py b/lib/silfont/scripts/psfsubset.py new file mode 100644 index 0000000..b207c3b --- /dev/null +++ b/lib/silfont/scripts/psfsubset.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python +__doc__ = '''Subset an existing UFO based on a csv or text list of glyph names or USVs to keep. +''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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 glyphlist', 'default': 'glyph_name'}, {}), + ('-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('Missing csv input field: ' + e.message, 'S') + except Exception as e: + logger.log('Error reading csv input field: ' + e.message, '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: + 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/lib/silfont/scripts/psfsyncmasters.py b/lib/silfont/scripts/psfsyncmasters.py new file mode 100644 index 0000000..4302d50 --- /dev/null +++ b/lib/silfont/scripts/psfsyncmasters.py @@ -0,0 +1,293 @@ +#!/usr/bin/env python +__doc__ = '''Sync metadata across a family of fonts based on designspace files''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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': 'Indicates complex set of fonts rather than RIBBI', '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", "trademark", + "woffMetadataCredits", "woffMetadataDescription") + fispecial = ("italicAngle", "openTypeOS2WeightClass", "openTypeNamePreferredSubfamilyName", "openTypeNameUniqueID", + "styleMapFamilyName", "styleMapStyleName", "styleName", "unitsPerEm") + fiall = sorted(set(ficopyreq) | set(ficopyopt) | set(fispecial)) + required = ficopyreq + ("openTypeOS2WeightClass", "styleName", "unitsPerEm") + libcopy = ("com.schriftgestaltung.glyphOrder", "public.glyphOrder", "public.postscriptNames") + logger = args.logger + complex = args.complex + + pds = DSD.DesignSpaceDocument() + pds.read(args.primaryds) + if args.secondds is not None: + sds = DSD.DesignSpaceDocument() + sds.read(args.secondds) + else: + sds = None + + # 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("Primary font: Italic angle must be non-zero for italic fonts", "E") + else: + if pval is not None and pval != 0 : + logger.log("Primary font: Italic angle must be zero for non-italic fonts", "E") + pval = None + elif field == "openTypeOS2WeightClass": + pval = int(psource.source.location["weight"]) + elif field == "styleMapFamilyName": + if not complex and pval is None: logger.log("styleMapFamilyName missing from primary font", "E") + elif field == "styleMapStyleName": + if not complex and pval not in ('regular', 'bold', 'italic', 'bold italic'): + logger.log("styleMapStyleName must be 'regular', 'bold', 'italic', 'bold italic'", "E") + 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 required: + 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, "Primary font: " + 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 libcopy: + pval = psource.lib.getval(field) if field in psource.lib else None + if pval is None: + 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": + sval = int(dsource.source.location["weight"]) + elif field == "styleMapStyleName": + if not complex and sval not in ('regular', 'bold', 'italic', 'bold italic'): + logger.log(dsource.source.filename + ": styleMapStyleName must be 'regular', 'bold', 'italic', 'bold italic'", "E") + 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 libcopy: + 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/lib/silfont/scripts/psfsyncmeta.py b/lib/silfont/scripts/psfsyncmeta.py new file mode 100755 index 0000000..e925fde --- /dev/null +++ b/lib/silfont/scripts/psfsyncmeta.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python +__doc__ = '''Sync metadata across a family of fonts assuming standard UFO file naming''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psftuneraliases.py b/lib/silfont/scripts/psftuneraliases.py new file mode 100644 index 0000000..f71d097 --- /dev/null +++ b/lib/silfont/scripts/psftuneraliases.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python +__doc__ = '''Merge lookup and feature aliases into TypeTuner feature file''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2019 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfufo2glyphs.py b/lib/silfont/scripts/psfufo2glyphs.py new file mode 100644 index 0000000..b40afcc --- /dev/null +++ b/lib/silfont/scripts/psfufo2glyphs.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python +__doc__ = '''Reads a designSpace file and create a Glyphs file from its linked ufos''' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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/lib/silfont/scripts/psfufo2ttf.py b/lib/silfont/scripts/psfufo2ttf.py new file mode 100644 index 0000000..832ef88 --- /dev/null +++ b/lib/silfont/scripts/psfufo2ttf.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python +__doc__ = 'Generate a ttf file without OpenType tables from a UFO' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2017 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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 + +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. + 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/lib/silfont/scripts/psfversion.py b/lib/silfont/scripts/psfversion.py new file mode 100755 index 0000000..12cdae9 --- /dev/null +++ b/lib/silfont/scripts/psfversion.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +__doc__ = 'Display version info for pysilfont and dependencies' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2018 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +import sys, importlib +import silfont + +def cmd() : + + deps = ( # (module, used by, min recommended version) + ('defcon', '?', ''), + ('fontbakery', '?', ''), + ('fontMath', '?', ''), + ('fontParts', '?', ''), + ('fontTools', '?', ''), + ('glyphConstruction', '?', ''), + ('glyphsLib', '?', ''), + ('lxml','?', ''), + ('lz4', '?', ''), + ('mutatorMath', '?', ''), + ('odf', '?', ''), + ('palaso', '?', ''), + ('tabulate', '?', ''), + ('ufo2ft', '?', ''), + ('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")[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/lib/silfont/scripts/psfwoffit.py b/lib/silfont/scripts/psfwoffit.py new file mode 100644 index 0000000..678cb19 --- /dev/null +++ b/lib/silfont/scripts/psfwoffit.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python +__doc__ = 'Convert font between ttf, woff, woff2' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2021 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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.privateData = 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/lib/silfont/scripts/psfxml2compdef.py b/lib/silfont/scripts/psfxml2compdef.py new file mode 100755 index 0000000..f5b5b35 --- /dev/null +++ b/lib/silfont/scripts/psfxml2compdef.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +__doc__ = 'convert composite definition file from XML format' +__url__ = 'http://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2015 SIL International (http://www.sil.org)' +__license__ = 'Released under the MIT License (http://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() -- cgit v1.2.3