diff options
Diffstat (limited to 'src/silfont/gfr.py')
-rw-r--r-- | src/silfont/gfr.py | 290 |
1 files changed, 290 insertions, 0 deletions
diff --git a/src/silfont/gfr.py b/src/silfont/gfr.py new file mode 100644 index 0000000..0e32169 --- /dev/null +++ b/src/silfont/gfr.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +__doc__ = '''General classes and functions for use with SIL's github fonts repository, github.com/silnrsi/fonts''' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2022 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +import os, json, io +import urllib.request as urllib2 +from zipfile import ZipFile +from silfont.util import prettyjson +from silfont.core import splitfn, loggerobj +from collections import OrderedDict +from fontTools.ttLib import TTFont + +familyfields = OrderedDict([ + ("familyid", {"opt": True, "manifest": False}), # req for families.json but not for base files; handled in code + ("fallback", {"opt": True, "manifest": False}), + ("family", {"opt": False, "manifest": True}), + ("altfamily", {"opt": True, "manifest": False}), + ("siteurl", {"opt": True, "manifest": False}), + ("packageurl", {"opt": True, "manifest": False}), + ("ziproot", {"opt": True, "manifest": False}), + ("files", {"opt": True, "manifest": True}), + ("defaults", {"opt": True, "manifest": True}), + ("version", {"opt": True, "manifest": True}), + ("status", {"opt": True, "manifest": False}), + ("license", {"opt": True, "manifest": False}), + ("distributable", {"opt": False, "manifest": False}), + ("source", {"opt": True, "manifest": False}), + ("googlefonts", {"opt": True, "manifest": False}), + ("features", {"opt": True, "manifest": False}) + ]) + +filefields = OrderedDict([ + ("altfamily", {"opt": True, "manifest": True, "mopt": True}), + ("url", {"opt": True, "manifest": False}), + ("flourl", {"opt": True, "manifest": False}), + ("packagepath", {"opt": True, "manifest": True}), + ("zippath", {"opt": True, "manifest": False}), + ("axes", {"opt": False, "manifest": True}) + ]) + +defaultsfields = OrderedDict([ + ("ttf", {"opt": True, "manifest": True}), + ("woff", {"opt": True, "manifest": True, "mopt": True}), + ("woff2", {"opt": True, "manifest": True, "mopt": True}) + ]) + +class _familydata(object): + """Family data key for use with families.json, font manifests and base files + """ + def __init__(self, id=None, data=None, filename = None, type="f", logger=None): + # Initial input can be a dictionary (data) in which case id nneds to be set + # or it can be read from a file (containing just one family record), in which case id is taken from the file + # Type can be f, b or m for families, base or manifest + # With f, this would be for just a single entry from a families.json file + self.id = id + self.data = data if data else {} + self.filename = filename + self.type = type + self.logger = logger if logger else loggerobj() + + def fieldscheck(self, data, validfields, reqfields, logprefix, valid, logs): + for key in data: # Check all keys have valid names + if key not in validfields: + logs.append((f'{logprefix}: Invalid field "{key}"', 'W')) + valid = False + continue + # Are required fields present + for key in reqfields: + if key not in data: + logs.append((f'{logprefix}: Required field "{key}" missing', 'W')) + valid = False + continue + return (valid, logs) + + def validate(self): + global familyfields, filefields, defaultsfields + logs = [] + valid = True + if self.type == "m": + validfields = reqfields = [key for key in familyfields if familyfields[key]["manifest"]] + else: + validfields = list(familyfields) + reqfields = [key for key in familyfields if not familyfields[key]["opt"]] + if self.type == "f": + reqfields = reqfields + ["familyid"] + else: # Must be b + validfields = validfields + ["hosturl", "filesroot"] + + (valid, logs) = self.fieldscheck(self.data, validfields, reqfields, "Main", valid, logs) + # Now check sub-fields + if "files" in self.data: + fdata = self.data["files"] + if self.type == "m": + validfields = [key for key in filefields if filefields[key]["manifest"]] + reqfields = [key for key in filefields if filefields[key]["manifest"] and not ("mopt" in filefields[key] and filefields[key]["mopt"])] + else: + validfields = list(filefields) + reqfields = [key for key in filefields if not filefields[key]["opt"]] + # Now need to check values for each record in files + for filen in fdata: + frecord = fdata[filen] + (valid, logs) = self.fieldscheck(frecord, validfields, reqfields, "Files: " + filen, valid, logs) + if "axes" in frecord: # (Will already have been reported above if axes is missing!) + adata = frecord["axes"] + avalidfields = [key for key in adata if len(key) == 4] + areqfields = ["wght", "ital"] if self.type == "m" else [] + (valid, logs) = self.fieldscheck(adata, avalidfields, areqfields, "Files, axes: " + filen, valid, logs) + if "defaults" in self.data: + ddata = self.data["defaults"] + if self.type == "m": + validfields = [key for key in defaultsfields if defaultsfields[key]["manifest"]] + reqfields = [key for key in defaultsfields if defaultsfields[key]["manifest"] and not ("mopt" in defaultsfields[key] and defaultsfields[key]["mopt"])] + else: + validfields = list(defaultsfields) + reqfields = [key for key in defaultsfields if not defaultsfields[key]["opt"]] + (valid, logs) = self.fieldscheck(ddata, validfields, reqfields, "Defaults:", valid, logs) + return (valid, logs) + + def read(self, filename=None): # Read data from file (not for families.json) + if filename: self.filename = filename + with open(self.filename) as infile: + try: + filedata = json.load(infile) + except Exception as e: + self.logger.log(f'Error opening {infile}: {e}', 'S') + if len(filedata) != 1: + self.logger.log(f'Files must contain just one record; {self.filename} has {len(filedata)}') + self.id = list(filedata.keys())[0] + self.data = filedata[self.id] + + def write(self, filename=None): # Write data to a file (not for families.json) + if filename is None: filename = self.filename + self.logger.log(f'Writing to {filename}', 'P') + filedata = {self.id: self.data} + with open(filename, "w", encoding="utf-8") as outf: + outf.write(prettyjson(filedata, oneliners=["files"])) + +class gfr_manifest(_familydata): + # + def __init__(self, id=None, data=None, filename = None, logger=None): + super(gfr_manifest, self).__init__(id=id, data=data, filename=filename, type="m", logger=logger) + + def validate(self, version=None, filename=None, checkfiles=True): + # Validate the manifest. + # If version is supplied, check that that matches the version in the manifest + # If self.filename not already set, the filename of the manifest must be supplied + (valid, logs) = super(gfr_manifest, self).validate() # Field name validation based on _familydata validation + + if filename is None: filename = self.filename + data = self.data + + if "files" in data and checkfiles: + files = data["files"] + mfilelist = {x: files[x]["packagepath"] for x in files} + + # Check files that are on disk match the manifest files + (path, base, ext) = splitfn(filename) + fontexts = ['.ttf', '.woff', '.woff2'] + dfilelist = {} + for dirname, subdirs, filenames in os.walk(path): + for filen in filenames: + (base, ext) = os.path.splitext(filen) + if ext in fontexts: + dfilelist[filen] = (os.path.relpath(os.path.join(dirname, filen), start=path).replace('\\', '/')) + + if mfilelist == dfilelist: + logs.append(('Files OK', 'I')) + else: + valid = False + logs.append(('Files on disk and in manifest do not match.', 'W')) + logs.append(('Files on disk:', 'I')) + for filen in sorted(dfilelist): + logs.append((f' {dfilelist[filen]}', 'I')) + logs.append(('Files in manifest:', 'I')) + for filen in sorted(mfilelist): + logs.append((f' {mfilelist[filen]}', 'I')) + + if "defaults" in data: + defaults = data["defaults"] + # Check defaults exist + allthere = True + for default in defaults: + if defaults[default] not in mfilelist: allthere = False + + if allthere: + logs.append(('Defaults OK', 'I')) + else: + valid = False + logs.append(('At least one default missing', 'W')) + + if version: + if "version" in data: + mversion = data["version"] + if version == mversion: + logs.append(('Versions OK', 'I')) + else: + valid = False + logs.append((f'Version mismatch: {version} supplied and {mversion} in manifest', "W")) + + return (valid, logs) + +class gfr_base(_familydata): + # + def __init__(self, id=None, data=None, filename = None, logger=None): + super(gfr_base, self).__init__(id=id, data=data, filename=filename, type="b", logger=logger) + +class gfr_family(object): # For families.json. + # + def __init__(self, data=None, filename=None, logger=None): + self.filename = filename + self.logger = logger if logger else loggerobj() + self.familyrecords = {} + if data is not None: self.familyrecords = data + + def validate(self, familyid=None): + allvalid = True + alllogs = [] + if familyid: + record = self.familyrecords[familyid] + (allvalid, alllogs) = record.validate() + else: + for familyid in self.familyrecords: + record = self.familyrecords[familyid] + (valid, logs) = record.validate() + if not valid: + allvalid = False + alllogs.append(logs) + return allvalid, alllogs + + def write(self, filename=None): # Write data to a file + if filename is None: filename = self.filename + self.logger.log(f'Writing to {filename}', "P") + with open(filename, "w", encoding="utf-8") as outf: + outf.write(prettyjson(self.familyrecords, oneliners=["files"])) + +def setpaths(logger): # Check that the script is being run from the root of the repository and set standard paths + repopath = os.path.abspath(os.path.curdir) + # Do cursory checks that this is the root of the fonts repo + if repopath[-5:] != "fonts" or not os.path.isdir(os.path.join(repopath, "fonts/sil")): + logger.log("GFR scripts must be run from the root of the fonts repo", "S") + # Set up standard paths for scripts to use + silpath = os.path.join(repopath, "fonts/sil") + otherpath = os.path.join(repopath, "fonts/other") + basespath = os.path.join(repopath, "basefiles") + if not os.path.isdir(basespath): os.makedirs(basespath) + return repopath, silpath, otherpath, basespath + +def getttfdata(ttf, logger): # Extract data from a ttf + + try: + font = TTFont(ttf) + except Exception as e: + logger.log(f'Error opening {ttf}: {e}', 'S') + + name = font['name'] + os2 = font['OS/2'] + post = font['post'] + + values = {} + + name16 = name.getName(nameID=16, platformID=3, platEncID=1, langID=0x409) + + values["family"] = str(name16) if name16 else str(name.getName(nameID=1, platformID=3, platEncID=1, langID=0x409)) + values["subfamily"] = str(name.getName(nameID=2, platformID=3, platEncID=1, langID=0x409)) + values["version"] = str(name.getName(nameID=5, platformID=3, platEncID=1, langID=0x409))[8:] # Remove "Version " from the front + values["wght"] = os2.usWeightClass + values["ital"] = 0 if getattr(post, "italicAngle") == 0 else 1 + + return values +def getziproot(url, ttfpath): + req = urllib2.Request(url=url, headers={'User-Agent': 'Mozilla/4.0 (compatible; httpget)'}) + try: + reqdat = urllib2.urlopen(req) + except Exception as e: + return (None, f'{url} not valid: {str(e)}') + zipdat = reqdat.read() + zipinfile = io.BytesIO(initial_bytes=zipdat) + try: + zipf = ZipFile(zipinfile) + except Exception as e: + return (None, f'{url} is not a valid zip file') + for zf in zipf.namelist(): + if zf.endswith(ttfpath): # found a font, assume we want it + ziproot = zf[:-len(ttfpath) - 1] # strip trailing / + return (ziproot, "") + else: + return (None, f"Can't find {ttfpath} in {url}") |