summaryrefslogtreecommitdiffstats
path: root/src/silfont/util.py
blob: bd9d5ee38b2bfe5a6d8ea9e947b3a4cd7aaf227e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
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)