diff options
author | Daniel Baumann <daniel@debian.org> | 2024-11-21 15:00:40 +0100 |
---|---|---|
committer | Daniel Baumann <daniel@debian.org> | 2024-11-21 15:00:40 +0100 |
commit | 012d9cb5faed22cb9b4151569d30cc08563b02d1 (patch) | |
tree | fd901b9c231aeb8afa713851f23369fa4a1af2b3 /src/silfont/util.py | |
parent | Initial commit. (diff) | |
download | pysilfont-012d9cb5faed22cb9b4151569d30cc08563b02d1.tar.xz pysilfont-012d9cb5faed22cb9b4151569d30cc08563b02d1.zip |
Adding upstream version 1.8.0.upstream/1.8.0upstream
Signed-off-by: Daniel Baumann <daniel@debian.org>
Diffstat (limited to 'src/silfont/util.py')
-rw-r--r-- | src/silfont/util.py | 394 |
1 files changed, 394 insertions, 0 deletions
diff --git a/src/silfont/util.py b/src/silfont/util.py new file mode 100644 index 0000000..bd9d5ee --- /dev/null +++ b/src/silfont/util.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +'General classes and functions for use in pysilfont scripts' +__url__ = 'https://github.com/silnrsi/pysilfont' +__copyright__ = 'Copyright (c) 2014 SIL International (https://www.sil.org)' +__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)' +__author__ = 'David Raymond' + +import os, subprocess, difflib, sys, io, json +from silfont.core import execute +from pkg_resources import resource_filename +from csv import reader as csvreader + +try: + from fontTools.ttLib import TTFont +except Exception as e: + TTFont = None + +class dirTree(dict) : + """ An object to hold list of all files and directories in a directory + with option to read sub-directory contents into dirTree objects. + Iterates through readSub levels of subfolders + Flags to keep track of changes to files etc""" + def __init__(self,dirn,readSub = 9999) : + self.removedfiles = {} # List of files that have been renamed or deleted since reading from disk + for name in os.listdir(dirn) : + if name[-1:] == "~" : continue + item=dirTreeItem() + if os.path.isdir(os.path.join(dirn, name)) : + item.type = "d" + if readSub : + item.dirtree = dirTree(os.path.join(dirn,name),readSub-1) + self[name] = item + + def subTree(self,path) : # Returns dirTree object for a subtree based on subfolder name(s) + # 'path' can be supplied as either a relative path (eg "subf/subsubf") or array (eg ['subf','subsubf'] + if type(path) in (bytes, str): path = self._split(path) + subf=path[0] + if subf in self: + dtree = self[subf].dirtree + else : return None + + if len(path) == 1 : + return dtree + else : + path.pop(0) + return dtree.subTree(path) + + def _split(self,path) : # Turn a relative path into an array of subfolders + npath = [os.path.split(path)[1]] + while os.path.split(path)[0] : + path = os.path.split(path)[0] + npath.insert(0,os.path.split(path)[1]) + return npath + +class dirTreeItem(object) : + + def __init__(self, type = "f", dirtree = None, read = False, added = False, changed = False, towrite = False, written = False, fileObject = None, fileType = None, flags = {}) : + self.type = type # "d" or "f" + self.dirtree = dirtree # dirtree for a sub-directory + # Remaining properties are for calling scripts to use as they choose to track actions etc + self.read = read # Item has been read by the script + self.added = added # Item has been added to dirtree, so does not exist on disk + self.changed = changed # Item has been changed, so may need updating on disk + self.towrite = towrite # Item should be written out to disk + self.written = written # Item has been written to disk + self.fileObject = fileObject # An object representing the file + self.fileType = fileType # The type of the file object + self.flags = {} # Any other flags a script might need + + def setinfo(self, read = None, added = None, changed = None, towrite = None, written = None, fileObject = None, fileType = None, flags = None) : + pass + if read : self.read = read + if added : self.added = added + if changed : self.changed = changed + if towrite: self.towrite = towrite + if written : self.written = written + if fileObject is not None : self.fileObject = fileObject + if fileType : self.fileType = fileType + if flags : self.flags = flags + +class ufo_diff(object): # For diffing 2 ufos as part of testing + # returncodes: + # 0 - ufos are the same + # 1 - Differences were found + # 2 - Errors running the difference (eg can't open file) + # diff - text of the differences + # errors - text of the errors + + def __init__(self, ufo1, ufo2, ignoreOHCtime=True): + + diffcommand = ["diff", "-r", "-c1", ufo1, ufo2] + + # By default, if only difference in fontinfo is the openTypeHeadCreated timestamp ignore that + + if ignoreOHCtime: # Exclude fontinfo if only diff is openTypeHeadCreated + # Otherwise leave it in so differences are reported by main diff + fi1 = os.path.join(ufo1,"fontinfo.plist") + fi2 = os.path.join(ufo2, "fontinfo.plist") + fitest = subprocess.Popen(["diff", fi1, fi2, "-c1"], stdout=subprocess.PIPE, stderr=subprocess.PIPE) + text = fitest.communicate() + if fitest.returncode == 1: + difftext = text[0].decode("utf-8").split("\n") + if difftext[4].strip() == "<key>openTypeHeadCreated</key>" and len(difftext) == 12: + diffcommand.append("--exclude=fontinfo.plist") + + # Now do the main diff + test = subprocess.Popen(diffcommand, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + text = test.communicate() + self.returncode = test.returncode + self.diff = text[0].decode("utf-8") + self.errors = text[1] + + def print_text(self): # Print diff info or errors from the diffcommand + if self.returncode == 0: + print("UFOs are the same") + elif self.returncode == 1: + print("UFOs are different") + print(self.diff) + elif self.returncode == 2: + print("Failed to compare UFOs") + print(self.errors) + +class text_diff(object): # For diffing 2 text files with option to ignore common timestamps + # See ufo_diff for class attribute details + + def __init__(self, file1, file2, ignore_chars=0, ignore_firstlinechars = 0): + # ignore_chars - characters to ignore from left of each line; typically 20 for timestamps + # ignore_firstlinechars - as above, but just for first line, eg for initial comment in csv files, typically 22 + errors = [] + try: + f1 = [x[ignore_chars:-1].replace('\\','/') for x in io.open(file1, "r", encoding="utf-8").readlines()] + except IOError: + errors.append("Can't open " + file1) + try: + f2 = [x[ignore_chars:-1].replace('\\','/') for x in io.open(file2, "r", encoding="utf-8").readlines()] + except IOError: + errors.append("Can't open " + file2) + if errors == []: # Indicates both files were opened OK + if ignore_firstlinechars: # Ignore first line for files with first line comment with timestamp + f1[0] = f1[0][ignore_firstlinechars:-1] + f2[0] = f2[0][ignore_firstlinechars:-1] + self.errors = "" + self.diff = "\n".join([x for x in difflib.unified_diff(f1, f2, file1, file2, n=0)]) + self.returncode = 0 if self.diff == "" else 1 + else: + self.diff = "" + self.errors = "\n".join(errors) + self.returncode = 2 + + def print_text(self): # Print diff info or errors the unified_diff command + if self.returncode == 0: + print("Files are the same") + elif self.returncode == 1: + print("Files are different") + print(self.diff) + elif self.returncode == 2: + print("Failed to compare Files") + print(self.errors) + +class ttf_diff(object): # For diffing 2 ttf files. Differences are not listed + # See ufo_diff for class attribute details + + def __init__(self, file1, file2): + errors=[] + if TTFont is None: + self.diff="" + self.errors="Testing failed - class ttf_diff requires fontTools to be installed" + self.returncode = 2 + return + + # Open the ttf files + try: + font1 = TTFont(file1) + except Exception as e: + errors.append("Can't open " + file1) + errors.append(e.__str__()) + try: + font2 = TTFont(file2) + except Exception as e: + errors.append("Can't open " + file2) + errors.append(e.__str__()) + if errors: + self.diff = "" + self.errors = "\n".join(errors) + self.returncode = 2 + return + + # Create ttx xml strings from each font + ttx1 = _ttx() + ttx2 = _ttx() + font1.saveXML(ttx1) + font2.saveXML(ttx2) + + if ttx1.txt() == ttx2.txt(): + self.diff = "" + self.errors = "" + self.returncode = 0 + else: + self.diff = file1 + " and " + file2 + " are different - compare with external tools" + self.errors = "" + self.returncode = 1 + + def print_text(self): # Print diff info or errors the unified_diff command + if self.returncode == 0: + print("Files are the same") + elif self.returncode == 1: + print("Files are different") + print(self.diff) + elif self.returncode == 2: + print("Failed to compare Files") + print(self.errors) + +def test_run(tool, commandline, testcommand, outfont, exp_errors, exp_warnings): # Used by tests to run commands + sys.argv = commandline.split(" ") + (args, font) = execute(tool, testcommand.doit, testcommand.argspec, chain="first") + if outfont: + if tool in ("FT", "FP"): + font.save(outfont) + else: # Must be Pyslifont Ufont + font.write(outfont) + args.logger.logfile.close() # Need to close the log so that the diff test can be run + exp_counts = (exp_errors, exp_warnings) + actual_counts = (args.logger.errorcount, args.logger.warningcount) + result = exp_counts == actual_counts + if not result: print("Mis-match of logger errors/warnings: " + str(exp_counts) + " vs " + str(actual_counts)) + return result + +def test_diffs(dirname, testname, extensions): # Used by test to run diffs on results files based on extensions + result = True + for ext in extensions: + resultfile = os.path.join("local/testresults", dirname, testname + ext) + referencefile = os.path.join("tests/reference", dirname, testname + ext) + if ext == ".ufo": + diff = ufo_diff(resultfile, referencefile) + elif ext == ".csv": + diff = text_diff(resultfile, referencefile, ignore_firstlinechars=22) + elif ext in (".log", ".lg"): + diff = text_diff(resultfile, referencefile, ignore_chars=20) + elif ext == ".ttf": + diff = ttf_diff(resultfile, referencefile) + else: + diff = text_diff(resultfile, referencefile) + + if diff.returncode: + diff.print_text() + result = False + return result + +class _ttx(object): # Used by ttf_diff() + + def __init__(self): + self.lines = [] + + def write(self, line): + if not("<checkSumAdjustment value=" in line or "<modified value=" in line) : + self.lines.append(line) + + def txt(self): + return "".join(self.lines) + +# Functions for mapping color def to names based on the colors provided by app UIs +namestocolorslist = { + 'g_red': '0.85,0.26,0.06,1', # g_ names refers to colors definable using the Glyphs UI + 'g_orange': '0.99,0.62,0.11,1', + 'g_brown': '0.65,0.48,0.2,1', + 'g_yellow': '0.97,1,0,1', + 'g_light_green': '0.67,0.95,0.38,1', + 'g_dark_green': '0.04,0.57,0.04,1', + 'g_cyan': '0,0.67,0.91,1', + 'g_blue': '0.18,0.16,0.78,1', + 'g_purple': '0.5,0.09,0.79,1', + 'g_pink': '0.98,0.36,0.67,1', + 'g_light_gray': '0.75,0.75,0.75,1', + 'g_dark_gray': '0.25,0.25,0.25,1' +} +colorstonameslist = {v: k for k, v in namestocolorslist.items()} + +def nametocolor(color, default=None): + global namestocolorslist + if default is not None: + return namestocolorslist.get(color,default) + else: + return namestocolorslist.get(color) + +def colortoname(color, default=None): + global colorstonameslist + if default: + return colorstonameslist.get(color,default) + else: + return colorstonameslist.get(color) + +def parsecolors(colors, single = False, allowspecial = False): # Process a list of colors - designed for handling command-line input + # Colors can be in RBGA format (eg (0.25,0.25,0.25,1)) or text name (eg g_dark_grey), separated by commas. + # Function returns a list of tuples, one per color, (RGBA, name, logcolor, original color after splitting) + # If the color can't be parsed, RGBA will be None and logocolor contain an error message + # If single is set, just return one tuple rather than a list of tuples + # Also can allow for special values of 'none' and 'leave' if allowspecial set + + # First tidy up the input string + cols = colors.lower().replace(" ", "") + + if single: # If just one color, don't need to split the string and can allow for RGBA without brackets + splitcols = ["(" + cols + ")"] if cols[0] in ("0", "1") else [cols] + else: + # Since RGBA colors which have parentheses and then commas within them, so can't just split on commas so add @ signs for first split + cols = cols.replace(",(", "@(").replace("),", ")@").split("@") + splitcols = [] + for color in cols: + if color[0] == "(": + splitcols.append(color) + else: + splitcols = splitcols + color.split(',') + parsed = [] + for splitcol in splitcols: + if allowspecial and splitcol in ("none", "leave"): + RGBA = "" + name = splitcol + logcolor = splitcol + else: + errormess = "" + name = "" + RGBA = "" + if splitcol[0] == '(': + values = splitcol[1:-1].split(',') # Remove parentheses then split on commas + if len(values) != 4: + errormess = "RGBA colours must have 4 values" + else: + for i in (0, 1, 2, 3): + values[i] = float(values[i]) + if values[i] < 0 or values[i] > 1: errormess = "RGBA values must be between 0 and 1" + if values[0] + values[1] + values[2] == 0: errormess = "At lease one RGB value must be non-zero" + if values[3] == 0: errormess = "With RGBA, A must not be zero" + if errormess == "": + for i in (0, 1, 2, 3): + v = values[i] + if v == int(v): v = int(v) # Convert integers to int type for correct formatting with str() + RGBA += str(v) + "," + RGBA = RGBA[0:-1] # Strip trialing comma + name = colortoname(RGBA, "") + else: + name = splitcol + RGBA = nametocolor(name) + if RGBA is None: errormess = "Invalid color name" + if errormess: + logcolor = "Invalid color: " + splitcol + " - " + errormess + RGBA = None + name = None + else: + logcolor = RGBA + if name: logcolor += " (" + name + ")" + parsed.append((RGBA, name, logcolor,splitcol)) + if single: parsed = parsed[0] + + return parsed + +# Provide dict of required characters which match the supplied list of sets - sets can be basic, rtl or sil +def required_chars(sets="basic"): + if type(sets) == str: sets = (sets,) # Convert single string to a tuple + rcfile = open(resource_filename('silfont','data/required_chars.csv')) + rcreader = csvreader(rcfile) + next(rcreader) # Read fist line which is headers + rcdict = {} + for line in rcreader: + unicode = line[0][2:] + item = { + "ps_name": line[1], + "glyph_name": line[2], + "sil_set": line[3], + "rationale": line[4], + "notes": line[5] + } + if item["sil_set"] in sets: rcdict[unicode] = item + return rcdict + +# Pretty print routine for json files. +def prettyjson(data, oneliners=None, indent="", inkey=None, oneline=False): + # data - json data to format + # oneliners - lists of keys for which data should be output on a single line + # indent, inkey & oneline - used when prettyjson calls itself interactively for sub-values that are dicts + res = ["{"] + thisoneline = oneline and (oneliners is None or inkey not in oneliners) + for key, value in sorted(data.items()): + line = ("" if thisoneline else indent) + '"{}": '.format(key) + if isinstance(value, dict): + val = prettyjson(value, oneliners=oneliners, + indent = indent + " ", inkey = key, + oneline=(oneline if oneliners is None or key not in oneliners else True)) + else: + val = json.dumps(value, ensure_ascii=False) + res.append(line + val + ",") + res[-1] = res[-1][:-1] + res.append(("" if thisoneline else indent[:-4]) + "}") + return (" " if thisoneline else "\n").join(res) + |