#!/usr/bin/env python3 __doc__ = '''Sync metadata across a family of fonts based on designspace files''' __url__ = 'https://github.com/silnrsi/pysilfont' __copyright__ = 'Copyright (c) 2018 SIL International (https://www.sil.org)' __license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' __author__ = 'David Raymond' from silfont.core import execute import silfont.ufo as UFO import silfont.etutil as ETU import os, datetime import fontTools.designspaceLib as DSD from xml.etree import ElementTree as ET argspec = [ ('primaryds', {'help': 'Primary design space file'}, {'type': 'filename'}), ('secondds', {'help': 'Second design space file', 'nargs': '?', 'default': None}, {'type': 'filename', 'def': None}), ('--complex', {'help': 'Obsolete - here for backwards compatibility only', 'action': 'store_true', 'default': False},{}), ('-l','--log', {'help': 'Log file'}, {'type': 'outfile', 'def': '_sync.log'}), ('-n','--new', {'help': 'append "_new" to file names', 'action': 'store_true', 'default': False},{}) # For testing/debugging ] def doit(args) : ficopyreq = ("ascender", "copyright", "descender", "familyName", "openTypeHheaAscender", "openTypeHheaDescender", "openTypeHheaLineGap", "openTypeNameDescription", "openTypeNameDesigner", "openTypeNameDesignerURL", "openTypeNameLicense", "openTypeNameLicenseURL", "openTypeNameManufacturer", "openTypeNameManufacturerURL", "openTypeNamePreferredFamilyName", "openTypeNameVersion", "openTypeOS2CodePageRanges", "openTypeOS2TypoAscender", "openTypeOS2TypoDescender", "openTypeOS2TypoLineGap", "openTypeOS2UnicodeRanges", "openTypeOS2VendorID", "openTypeOS2WinAscent", "openTypeOS2WinDescent", "versionMajor", "versionMinor") ficopyopt = ("openTypeNameSampleText", "postscriptFamilyBlues", "postscriptFamilyOtherBlues", "styleMapFamilyName", "trademark", "woffMetadataCredits", "woffMetadataDescription") fispecial = ("italicAngle", "openTypeOS2WeightClass", "openTypeNamePreferredSubfamilyName", "openTypeNameUniqueID", "styleName", "unitsPerEm") fiall = sorted(set(ficopyreq) | set(ficopyopt) | set(fispecial)) firequired = ficopyreq + ("openTypeOS2WeightClass", "styleName", "unitsPerEm") libcopyreq = ("com.schriftgestaltung.glyphOrder", "public.glyphOrder", "public.postscriptNames") libcopyopt = ("public.skipExportGlyphs",) liball = sorted(set(libcopyreq) | set(libcopyopt)) logger = args.logger pds = DSD.DesignSpaceDocument() pds.read(args.primaryds) if args.secondds is not None: sds = DSD.DesignSpaceDocument() sds.read(args.secondds) else: sds = None # Extract weight mappings from axes pwmap = swmap = {} for (ds, wmap, name) in ((pds, pwmap, "primary"),(sds, swmap, "secondary")): if ds: rawmap = None for descriptor in ds.axes: if descriptor.name == "weight": rawmap = descriptor.map break if rawmap: for (cssw, xvalue) in rawmap: wmap[int(xvalue)] = int(cssw) else: logger.log(f"No weight axes mapping in {name} design space", "W") # Process all the sources psource = None dsources = [] for source in pds.sources: if source.copyInfo: if psource: logger.log('Multiple fonts with ', "S") psource = Dsource(pds, source, logger, frompds=True, psource = True, args = args) else: dsources.append(Dsource(pds, source, logger, frompds=True, psource = False, args = args)) if sds is not None: for source in sds.sources: dsources.append(Dsource(sds, source, logger, frompds=False, psource = False, args=args)) # Process values in psource fipval = {} libpval = {} changes = False reqmissing = False for field in fiall: pval = psource.fontinfo.getval(field) if field in psource.fontinfo else None oval = pval # Set values or do other checks for special cases if field == "italicAngle": if "italic" in psource.source.filename.lower(): if pval is None or pval == 0 : logger.log(f"{psource.source.filename}: Italic angle must be non-zero for italic fonts", "E") else: if pval is not None and pval != 0 : logger.log(f"{psource.source.filename}: Italic angle must be zero for non-italic fonts", "E") pval = None elif field == "openTypeOS2WeightClass": desweight = int(psource.source.location["weight"]) if desweight in pwmap: pval = pwmap[desweight] else: logger.log(f"Design weight {desweight} not in axes mapping so openTypeOS2WeightClass not updated", "I") elif field in ("styleName", "openTypeNamePreferredSubfamilyName"): pval = psource.source.styleName elif field == "openTypeNameUniqueID": nm = str(fipval["openTypeNameManufacturer"]) # Need to wrap with str() just in case missing from fn = str(fipval["familyName"]) # fontinfo so would have been set to None sn = psource.source.styleName pval = nm + ": " + fn + " " + sn + ": " + datetime.datetime.now().strftime("%Y") elif field == "unitsperem": if pval is None or pval <= 0: logger.log("unitsperem must be non-zero", "S") # After processing special cases, all required fields should have values if pval is None and field in firequired: reqmissing = True logger.log("Required fontinfo field " + field + " missing from " + psource.source.filename, "E") elif oval != pval: changes = True if pval is None: if field in psource.fontinfo: psource.fontinfo.remove(field) else: psource.fontinfo[field][1].text = str(pval) logchange(logger, f"{psource.source.filename}: {field} updated:", oval, pval) fipval[field] = pval if reqmissing: logger.log("Required fontinfo fields missing from " + psource.source.filename, "S") if changes: psource.fontinfo.setval("openTypeHeadCreated", "string", datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S")) psource.write("fontinfo") for field in liball: pval = psource.lib.getval(field) if field in psource.lib else None if pval is None: if field in libcopyreq: logtype = "W" if field[0:7] == "public." else "I" logger.log("lib.plist field " + field + " missing from " + psource.source.filename, logtype) libpval[field] = pval # Now update values in other source fonts for dsource in dsources: logger.log("Processing " + dsource.ufodir, "I") fchanges = False for field in fiall: sval = dsource.fontinfo.getval(field) if field in dsource.fontinfo else None oval = sval pval = fipval[field] # Set values or do other checks for special cases if field == "italicAngle": if "italic" in dsource.source.filename.lower(): if sval is None or sval == 0: logger.log(dsource.source.filename + ": Italic angle must be non-zero for italic fonts", "E") else: if sval is not None and sval != 0: logger.log(dsource.source.filename + ": Italic angle must be zero for non-italic fonts", "E") sval = None elif field == "openTypeOS2WeightClass": desweight = int(dsource.source.location["weight"]) if desweight in swmap: sval = swmap[desweight] else: logger.log(f"Design weight {desweight} not in axes mapping so openTypeOS2WeightClass not updated", "I") elif field in ("styleName", "openTypeNamePreferredSubfamilyName"): sval = dsource.source.styleName elif field == "openTypeNameUniqueID": sn = dsource.source.styleName sval = nm + ": " + fn + " " + sn + ": " + datetime.datetime.now().strftime("%Y") else: sval = pval if oval != sval: if field == "unitsPerEm": logger.log("unitsPerEm inconsistent across fonts", "S") fchanges = True if sval is None: dsource.fontinfo.remove(field) logmess = " removed: " else: logmess = " added: " if oval is None else " updated: " # Copy value from primary. This will add if missing. dsource.fontinfo.setelem(field, ET.fromstring(ET.tostring(psource.fontinfo[field][1]))) # For fields where it is not a copy from primary... if field in ("italicAngle", "openTypeNamePreferredSubfamilyName", "openTypeNameUniqueID", "openTypeOS2WeightClass", "styleName"): dsource.fontinfo[field][1].text = str(sval) logchange(logger, dsource.source.filename + " " + field + logmess, oval, sval) lchanges = False for field in liball: oval = dsource.lib.getval(field) if field in dsource.lib else None pval = libpval[field] if oval != pval: lchanges = True if pval is None: dsource.lib.remove(field) logmess = " removed: " else: dsource.lib.setelem(field, ET.fromstring(ET.tostring(psource.lib[field][1]))) logmess = " updated: " logchange(logger, dsource.source.filename + " " + field + logmess, oval, pval) if lchanges: dsource.write("lib") fchanges = True # Force fontinfo to update so openTypeHeadCreated is set if fchanges: dsource.fontinfo.setval("openTypeHeadCreated", "string", datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S")) dsource.write("fontinfo") logger.log("psfsyncmasters completed", "P") class Dsource(object): def __init__(self, ds, source, logger, frompds, psource, args): self.ds = ds self.source = source self.logger = logger self.frompds = frompds # Boolean to say if came from pds self.newfile = "_new" if args.new else "" self.ufodir = source.path if not os.path.isdir(self.ufodir): logger.log(self.ufodir + " in designspace doc does not exist", "S") try: self.fontinfo = UFO.Uplist(font=None, dirn=self.ufodir, filen="fontinfo.plist") except Exception as e: logger.log("Unable to open fontinfo.plist in " + self.ufodir, "S") try: self.lib = UFO.Uplist(font=None, dirn=self.ufodir, filen="lib.plist") except Exception as e: if psource: logger.log("Unable to open lib.plist in " + self.ufodir, "E") self.lib = {} # Just need empty dict, so all vals will be set to None else: logger.log("Unable to open lib.plist in " + self.ufodir + "; creating empty one", "E") self.lib = UFO.Uplist() self.lib.logger=logger self.lib.etree = ET.fromstring("\n\n") 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. 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 '''