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
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
|
#!/usr/bin/env python3
'General classes and functions for use in pysilfont scripts'
__url__ = 'https://github.com/silnrsi/pysilfont'
__copyright__ = 'Copyright (c) 2014-2023 SIL International (https://www.sil.org)'
__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)'
__author__ = 'David Raymond'
from glob import glob
from collections import OrderedDict
import sys, os, argparse, datetime, shutil, csv, configparser
import silfont
class loggerobj(object):
# For handling log messages.
# Use S for severe errors caused by data, parameters supplied by user etc
# Use X for severe errors caused by bad code to get traceback exception
def __init__(self, logfile=None, loglevels="", leveltext="", loglevel="W", scrlevel="P"):
self.logfile = logfile
self.loglevels = loglevels
self.leveltext = leveltext
self.errorcount = 0
self.warningcount = 0
if not self.loglevels: self.loglevels = {'X': 0, 'S': 1, 'E': 2, 'P': 3, 'W': 4, 'I': 5, 'V': 6}
if not self.leveltext: self.leveltext = ('Exception ', 'Severe: ', 'Error: ', 'Progress: ', 'Warning: ', 'Info: ', 'Verbose: ')
super(loggerobj, self).__setattr__("loglevel", "E") # Temp values so invalid log levels can be reported
super(loggerobj, self).__setattr__("scrlevel", "E") #
self.loglevel = loglevel
self.scrlevel = scrlevel
def __setattr__(self, name, value):
if name in ("loglevel", "scrlevel"):
if value in self.loglevels:
(minlevel, minnum) = ("E",2) if name == "loglevel" else ("S", 1)
if self.loglevels[value] < minnum:
value = minlevel
self.log(name + " increased to minimum level of " + minlevel, "E")
else:
self.log("Invalid " + name + " value: " + value, "S")
super(loggerobj, self).__setattr__(name, value)
if name == "scrlevel" : self._basescrlevel = value # Used by resetscrlevel
def log(self, logmessage, msglevel="W"):
levelval = self.loglevels[msglevel]
message = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S ") + self.leveltext[levelval] + str(logmessage)
#message = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[0:22] +" "+ self.leveltext[levelval] + logmessage ## added milliseconds for timing tests
if levelval <= self.loglevels[self.scrlevel]: print(message)
if self.logfile and levelval <= self.loglevels[self.loglevel]: self.logfile.write(message + "\n")
if msglevel == "S":
print("\n **** Fatal error - exiting ****")
sys.exit(1)
if msglevel == "X": assert False, message
if msglevel == "E": self.errorcount += 1
if msglevel == "W": self.warningcount += 1
def raisescrlevel(self, level): # Temporarily increase screen logging
if level not in self.loglevels or level == "X" : self.log("Invalid scrlevel: " + level, "X")
if self.loglevels[level] > self.loglevels[self.scrlevel]:
current = self.scrlevel
self.scrlevel = level
self._basescrlevel = current
self.log("scrlevel raised to " + level, "I")
def resetscrlevel(self):
self.scrlevel = self._basescrlevel
class parameters(object):
# Object for holding parameters information, organised by class (eg logging)
# Default parameters for use in pysilfont modules
# Names must be case-insensitively unique across all parameter classes
# Parameter types are deduced from the default values
def __init__(self):
# Default parameters for all modules
defparams = {}
defparams['system'] = {'version': silfont.__version__, 'copyright': silfont.__copyright__} # Code treats these as read-only
defparams['logging'] = {'scrlevel': 'P', 'loglevel': 'W'}
defparams['backups'] = {'backup': True, 'backupdir': 'backups', 'backupkeep': 5}
# Default parameters for UFO module
defparams['outparams'] = OrderedDict([ # Use ordered dict so parameters show in logical order with -h p
("UFOversion", ""), # UFOversion - defaults to existing unless a value is supplied
("indentIncr", " "), # XML Indent increment
("indentFirst", " "), # First XML indent
("indentML", False), # Should multi-line string values be indented?
("plistIndentFirst", ""), # First indent amount for plists
('precision', 6), # Decimal precision to use in XML output - both for real values and for attributes if float
("floatAttribs", ['xScale', 'xyScale', 'yxScale', 'yScale', 'angle']), # Used with precision above
("intAttribs", ['pos', 'width', 'height', 'xOffset', 'yOffset', 'x', 'y']),
("sortDicts", True), # Should dict elements be sorted alphabetically?
("renameGlifs", True), # Rename glifs based on UFO3 suggested algorithm
("format1Glifs", False), # Force output format 1 glifs including UFO2-style anchors (for use with FontForge
("glifElemOrder", ['advance', 'unicode', 'note', 'image', 'guideline', 'anchor', 'outline', 'lib']), # Order to output glif elements
("attribOrders.glif",['pos', 'width', 'height', 'fileName', 'base', 'xScale', 'xyScale', 'yxScale', 'yScale', 'xOffset', 'yOffset',
'x', 'y', 'angle', 'type', 'smooth', 'name', 'format', 'color', 'identifier'])
])
defparams['ufometadata'] = {"checkfix": "check"} # Apply metadata fixes when reading UFOs
self.paramshelp = {} # Info used when outputting help about parame options
self.paramshelp["classdesc"] = {
"logging": "controls the level of log messages go to screen or log files.",
"backups": "controls backup settings for scripts that output fonts - by default backups are made if the output font is overwriting the input font",
"outparams": "Output options for UFOs - cover UFO version and normalization",
"ufometadata": "controls if UFO metadata be checked, or checked and fixed"
}
self.paramshelp["paramsdesc"] = {
"scrlevel": "Logging level for screen messages - one of S,E,P.W,I or V",
"loglevel": "Logging level for log file messages - one of E,P.W,I or V",
"backup": "Should font backups be made",
"backupdir": "Directory to use for font backups",
"backupkeep": "How many backups to keep",
"indentIncr": "XML Indent increment",
"indentFirst": "First XML indent",
"indentML": "Should multi-line string values be indented?",
"plistIndentFirst": "First indent amount for plists",
"sortDicts": "Should dict elements be sorted alphabetically?",
"precision": "Decimal precision to use in XML output - both for real values and for attributes if numeric",
"renameGlifs": "Rename glifs based on UFO3 suggested algorithm",
"UFOversion": "UFOversion to output - defaults to version of the input UFO",
"format1Glifs": "Force output format 1 glifs including UFO2-style anchors (was used with FontForge; no longer needed)",
"glifElemOrder": "Order to output glif elements",
"floatAttribs": "List of float attributes - used when setting decimal precision",
"intAttribs": "List of attributes that should be integers",
"attribOrders.glif": "Order in which to output glif attributes",
"checkfix": "Should check & fix tests be done - one of None, Check or Fix"
}
self.paramshelp["defaultsdesc"] = { # For use where default needs clarifying with text
"indentIncr" : "<two spaces>",
"indentFirst": "<two spaces>",
"plistIndentFirst": "<No indent>",
"UFOversion": "<Existing version>"
}
self.classes = {} # Dictionary containing a list of parameters in each class
self.paramclass = {} # Dictionary of class name for each parameter name
self.types = {} # Python type for each parameter deduced from initial values supplied
self.listtypes = {} # If type is dict, the type of values in the dict
self.logger = loggerobj()
defset = _paramset(self, "default", "defaults")
self.sets = {"default": defset}
self.lcase = {} # Lower case index of parameters names
for classn in defparams:
self.classes[classn] = []
for parn in defparams[classn]:
value = defparams[classn][parn]
self.classes[classn].append(parn)
self.paramclass[parn] = classn
self.types[parn] = type(value)
if type(value) is list: self.listtypes[parn] = type(value[0])
super(_paramset, defset).__setitem__(parn, value) # __setitem__ in paramset does not allow new values!
self.lcase[parn.lower()] = parn
def addset(self, name, sourcedesc=None, inputdict=None, configfile=None, copyset=None):
# Create a subset from one of a dict, config file or existing set
# Only one option should used per call
# sourcedesc should be added for user-supplied data (eg config file) for reporting purposes
dict = {}
if configfile:
config = configparser.ConfigParser()
config.read_file(open(configfile, encoding="utf-8"))
if sourcedesc is None: sourcedesc = configfile
for classn in config.sections():
for item in config.items(classn):
parn = item[0]
if self.paramclass[parn] == "system":
self.logger.log("Can't change " + parn + " parameter via config file", "S")
val = item[1].strip('"').strip("'")
dict[parn] = val
elif copyset:
if sourcedesc is None: sourcedesc = "Copy of " + copyset
for parn in self.sets[copyset]:
dict[parn] = self.sets[copyset][parn]
elif inputdict:
dict = inputdict
if sourcedesc is None: sourcedesc = "unspecified source"
self.sets[name] = _paramset(self, name, sourcedesc, dict)
def printhelp(self):
phelp = self.paramshelp
print("\nMost pysilfont scripts have -p, --params options which can be used to change default behaviour of scripts. For example '-p scrlevel=w' will log warning messages to screen \n")
print("Listed below are all such parameters, grouped by purpose. Not all apply to all scripts - "
"in partucular outparams and ufometadata only apply to scripts using pysilfont's own UFO code")
for classn in ("logging", "backups", "ufometadata", "outparams"):
print("\n" + classn[0].upper() + classn[1:] + " - " + phelp["classdesc"][classn])
for param in self.classes[classn]:
if param == "format1Glifs": continue # Param due to be phased out
paramdesc = phelp["paramsdesc"][param]
paramtype = self.types[param].__name__
defaultdesc = phelp["defaultsdesc"][param] if param in phelp["defaultsdesc"] else self.sets["default"][param]
print(' {:<20}: {}'.format(param, paramdesc))
print(' (Type: {:<6} Default: {})'.format(paramtype + ",", defaultdesc))
print("\nNote parameter names are case-insensitive\n")
print("For more help see https://github.com/silnrsi/pysilfont/blob/master/docs/parameters.md\n")
class _paramset(dict):
# Set of parameter values
def __init__(self, params, name, sourcedesc, inputdict=None):
if inputdict is None: inputdict = {}
self.name = name
self.sourcedesc = sourcedesc # Description of source for reporting
self.params = params # Parent parameters object
for parn in inputdict:
if params.paramclass[parn] == "system": # system values can't be changed
if inputdict[parn] != params.sets["default"][parn]:
self.params.logger.log("Can't change " + parn + " - system parameters can't be changed", "X")
else:
super(_paramset, self).__setitem__(parn, inputdict[parn])
else:
self[parn] = inputdict[parn]
def __setitem__(self, parn, value):
origvalue = value
origparn = parn
parn = parn.lower()
if self.params.paramclass[origparn] == "system":
self.params.logger.log("Can't change " + parn + " - system parameters are read-only", "X")
if parn not in self.params.lcase:
self.params.logger.log("Invalid parameter " + origparn + " from " + self.sourcedesc, "S")
else:
parn = self.params.lcase[parn]
ptyp = self.params.types[parn]
if ptyp is bool:
value = str2bool(value)
if value is None: self.params.logger.log(self.sourcedesc+" parameter "+origparn+" must be boolean: " + origvalue, "S")
if ptyp is list:
if type(value) is not list: value = value.split(",") # Convert csv string into list
if len(value) < 2: self.params.logger.log(self.sourcedesc+" parameter "+origparn+" must have a list of values: " + origvalue, "S")
valuesOK = True
listtype = self.params.listtypes[parn]
for i, val in enumerate(value):
if listtype is bool:
val = str2bool(val)
if val is None: self.params.logger.log (self.sourcedesc+" parameter "+origparn+" must contain boolean values: " + origvalue, "S")
value[i] = val
if type(val) != listtype:
valuesOK = False
badtype = str(type(val))
if not valuesOK: self.params.logger.log("Invalid "+badtype+" parameter type for "+origparn+": "+self.params.types[parn], "S")
if parn in ("loglevel", "scrlevel"): # Need to check log level is valid before setting it since otherwise logging will fail
value = value.upper()
if value not in self.params.logger.loglevels: self.params.logger.log (self.sourcedesc+" parameter "+parn+" invalid", "S")
super(_paramset, self).__setitem__(parn, value)
def updatewith(self, update, sourcedesc=None, log=True):
# Update a set with values from another set
if sourcedesc is None: sourcedesc = self.params.sets[update].sourcedesc
for parn in self.params.sets[update]:
oldval = self[parn] if parn in self else ""
self[parn] = self.params.sets[update][parn]
if log and oldval != "" and self[parn] != oldval:
old = str(oldval)
new = str(self[parn])
if old != old.strip() or new != new.strip(): # Add quotes if there are leading or trailing spaces
old = '"'+old+'"'
new = '"'+new+'"'
self.params.logger.log(sourcedesc + " parameters: changing "+parn+" from " + old + " to " + new, "I")
class csvreader(object): # Iterator for csv files, skipping comments and checking number of fields
def __init__(self, filename, minfields=0, maxfields=999, numfields=None, logger=None):
self.filename = filename
self.minfields = minfields
self.maxfields = maxfields
self.numfields = numfields
self.logger = logger if logger else loggerobj() # If no logger supplied, will just log to screen
# Open the file and create reader
try:
file = open(filename, "rt", encoding="utf-8")
except Exception as e:
print(e)
sys.exit(1)
self.file = file
self.reader = csv.reader(file)
# Find the first non-comment line then reset so __iter__ still returns the first line
# This is so scripts can analyse first line (eg to look for headers) before starting iterating
self.firstline = None
self._commentsbeforefirstline = -1
while not self.firstline:
row = next(self.reader, None)
if row is None: logger.log("Input csv is empty or all lines are comments or blank", "S")
self._commentsbeforefirstline += 1
if row == []: continue # Skip blank lines
if row[0].lstrip().startswith("#"): continue # Skip comments - ie lines starting with #
self.firstline = row
file.seek(0) # Reset the csv and skip comments
for i in range(self._commentsbeforefirstline): next(self.reader, None)
def __setattr__(self, name, value):
if name == "numfields" and value is not None: # If numfields is changed, reset min and max fields
self.minfields = value
self.maxfields = value
super(csvreader, self).__setattr__(name, value)
def __iter__(self):
for row in self.reader:
self.line_num = self.reader.line_num - 1 - self._commentsbeforefirstline # Count is out due to reading first line in __init__
if row == []: continue # Skip blank lines
if row[0].lstrip().startswith("#"): continue # Skip comments - ie lines starting with #
if len(row) < self.minfields or len(row) > self.maxfields:
self.logger.log("Invalid number of fields on line " + str(self.line_num) + " in "+self.filename, "E" )
continue
yield row
def execute(tool, fn, scriptargspec, chain = None):
# Function to handle parameter parsing, font and file opening etc in command-line scripts
# Supports opening (and saving) fonts using PysilFont UFO (UFO), fontParts (FP) or fontTools (FT)
# Special handling for:
# -d variation on -h to print extra info about defaults
# -q quiet mode - only output a single line with count of errors (if there are any)
# -l opens log file and also creates a logger function to write to the log file
# -p other parameters. Includes backup settings and loglevel/scrlevel settings for logger
# for UFOlib scripts, also includes all outparams keys and ufometadata settings
argspec = list(scriptargspec)
chainfirst = False
if chain == "first": # If first call to execute has this set, only do the final return part of chaining
chainfirst = True
chain = None
params = chain["params"] if chain else parameters()
logger = chain["logger"] if chain else params.logger # paramset has already created a basic logger
argv = chain["argv"] if chain else sys.argv
if tool == "UFO":
from silfont.ufo import Ufont
elif tool == "FT":
from fontTools import ttLib
elif tool == "FP":
from fontParts.world import OpenFont
elif tool == "" or tool is None:
tool = None
else:
logger.log("Invalid tool in call to execute()", "X")
return
basemodule = sys.modules[fn.__module__]
poptions = {}
poptions['prog'] = splitfn(argv[0])[1]
poptions['description'] = basemodule.__doc__
poptions['formatter_class'] = argparse.RawDescriptionHelpFormatter
epilog = "For more help options use -h ?. For more documentation see https://github.com/silnrsi/pysilfont/blob/master/docs/scripts.md#" + poptions['prog'] + "\n\n"
poptions['epilog'] = epilog + "Version: " + params.sets['default']['version'] + "\n" + params.sets['default']['copyright']
parser = argparse.ArgumentParser(**poptions)
parser._optionals.title = "other arguments"
# Add standard arguments
standardargs = {
'quiet': ('-q', '--quiet', {'help': 'Quiet mode - only display severe errors', 'action': 'store_true'}, {}),
'log': ('-l', '--log', {'help': 'Log file'}, {'type': 'outfile'}),
'params': ('-p', '--params', {'help': 'Other parameters - see parameters.md for details', 'action': 'append'}, {'type': 'optiondict'}),
'nq': ('--nq', {'help': argparse.SUPPRESS, 'action': 'store_true'}, {})}
suppliedargs = []
for a in argspec:
argn = a[:-2][-1] # [:-2] will give either 1 or 2, the last of which is the full argument name
if argn[0:2] == "--": argn = argn[2:] # Will start with -- for options
suppliedargs.append(argn)
for arg in sorted(standardargs):
if arg not in suppliedargs: argspec.append(standardargs[arg])
defhelp = False
if "-h" in argv: # Look for help option supplied
pos = argv.index("-h")
if pos < len(argv)-1: # There is something following -h!
opt = argv[pos+1]
if opt in ("d", "defaults"):
defhelp = True # Normal help will be displayed with default info displayed by the epilog
deffiles = []
defother = []
elif opt in ("p", "params"):
params.printhelp()
sys.exit(0)
else:
if opt != "?":
print("Invalid -h value")
print("-h ? displays help options")
print("-h d (or -h defaults) lists details of default values for arguments and parameters")
print("-h p (or -h params) gives help on parameters that can be set with -p or --params")
sys.exit(0)
quiet = True if "-q" in argv and '--nq' not in argv else False
if quiet: logger.scrlevel = "S"
# Process the supplied argument specs, add args to parser, store other info in arginfo
arginfo = []
logdef = None
for a in argspec:
# Process all but last tuple entry as argparse arguments
nonkwds = a[:-2]
kwds = a[-2]
try:
parser.add_argument(*nonkwds, **kwds)
except Exception as e:
print(f'nonkwds: {nonkwds}, kwds: {kwds}')
print(e)
sys.exit(1)
# Create ainfo, a dict of framework keywords using argument name
argn = nonkwds[-1] # Find the argument name from first 1 or 2 tuple entries
if argn[0:2] == "--": # Will start with -- for options
argn = argn[2:].replace("-", "_") # Strip the -- and replace any - in name with _
ainfo=dict(a[-1]) #Make a copy so original argspec is not changed
for key in ainfo: # Check all keys are valid
if key not in ("def", "type", "optlog") : logger.log("Invalid argspec framework key: " + key, "X")
ainfo['name']=argn
if argn == 'log':
logdef = ainfo['def'] if 'def' in ainfo else None
optlog = ainfo['optlog'] if 'optlog' in ainfo else False
arginfo.append(ainfo)
if defhelp:
arg = nonkwds[0]
if 'def' in ainfo:
defval = ainfo['def']
if argn == 'log' and logdef: defval += " in logs subdirectory"
deffiles.append([arg, defval])
elif 'default' in kwds:
defother.append([arg, kwds['default']])
# if -h d specified, change the help epilog to info about argument defaults
if defhelp:
if not (deffiles or defother):
deftext = "No defaults for parameters/options"
else:
deftext = "Defaults for parameters/options - see user docs for details\n"
if deffiles:
deftext = deftext + "\n Font/file names\n"
for (param, defv) in deffiles:
deftext = deftext + ' {:<20}{}\n'.format(param, defv)
if defother:
deftext = deftext + "\n Other parameters\n"
for (param, defv) in defother:
deftext = deftext + ' {:<20}{}\n'.format(param, defv)
parser.epilog = deftext + "\n\n" + parser.epilog
# Parse the command-line arguments. If errors or -h used, procedure will exit here
args = parser.parse_args(argv[1:])
# Process the first positional parameter to get defaults for file names
fppval = getattr(args, arginfo[0]['name'])
if isinstance(fppval, list): # When nargs="+" or nargs="*" is used a list is returned
(fppath, fpbase, fpext) = splitfn(fppval[0])
if len(fppval) > 1 : fpbase = "wildcard"
else:
if fppval is None: fppval = "" # For scripts that can be run with no positional parameters
(fppath, fpbase, fpext) = splitfn(fppval) # First pos param use for defaulting
# Process parameters
if chain:
execparams = params.sets["main"]
args.params = {} # clparams not used when chaining
else:
# Read config file from disk if it exists
configname = os.path.join(fppath, "pysilfont.cfg")
if os.path.exists(configname):
params.addset("config file", configname, configfile=configname)
else:
params.addset("config file") # Create empty set
if not quiet and "scrlevel" in params.sets["config file"]: logger.scrlevel = params.sets["config file"]["scrlevel"]
# Process command-line parameters
clparams = {}
if 'params' in args.__dict__:
if args.params is not None:
for param in args.params:
x = param.split("=", 1)
if len(x) != 2:
logger.log("params must be of the form 'param=value'", "S")
if x[1] == "\\t": x[1] = "\t" # Special handling for tab characters
clparams[x[0]] = x[1]
args.params = clparams
params.addset("command line", "command line", inputdict=clparams)
if not quiet and "scrlevel" in params.sets["command line"]: logger.scrlevel = params.sets["command line"]["scrlevel"]
# Create main set of parameters based on defaults then update with config file values and command line values
params.addset("main", copyset="default")
params.sets["main"].updatewith("config file")
params.sets["main"].updatewith("command line")
execparams = params.sets["main"]
# Set up logging
if chain:
setattr(args, 'logger', logger)
args.logfile = logger.logfile
else:
logfile = None
logname = args.log if 'log' in args.__dict__ and args.log is not None else ""
if 'log' in args.__dict__:
if logdef is not None and (logname != "" or optlog == False):
(path, base, ext) = splitfn(logname)
(dpath, dbase, dext) = splitfn(logdef)
if not path:
if base and ext: # If both specified then use cwd, ie no path
path = ""
else:
path = (fppath if dpath == "" else os.path.join(fppath, dpath))
path = os.path.join(path, "logs")
if not base:
if dbase == "":
base = fpbase
elif dbase[0] == "_": # Append to font name if starts with _
base = fpbase + dbase
else:
base = dbase
if not ext and dext: ext = dext
logname = os.path.join(path, base+ext)
if logname == "":
logfile = None
else:
(logname, logpath, exists) = fullpath(logname)
if not exists:
(parent,subd) = os.path.split(logpath)
if subd == "logs" and os.path.isdir(parent): # Create directory if just logs subdir missing
logger.log("Creating logs subdirectory in " + parent, "P")
os.makedirs(logpath, exist_ok=True)
else: # Fails, since missing dir is probably a typo!
logger.log("Directory " + parent + " does not exist", "S")
logger.log('Opening log file for output: ' + logname, "P")
try:
logfile = open(logname, "w", encoding="utf-8")
except Exception as e:
print(e)
sys.exit(1)
args.log = logfile
# Set up logger details
logger.loglevel = execparams['loglevel'].upper()
logger.logfile = logfile
if not quiet: logger.scrlevel = "E" # suppress next log message from screen
logger.log("Running: " + " ".join(argv), "P")
if not quiet: logger.scrlevel = execparams['scrlevel'].upper()
setattr(args, 'logger', logger)
# Process the argument values returned from argparse
outfont = None
infontlist = []
for c, ainfo in enumerate(arginfo):
aval = getattr(args, ainfo['name'])
if ainfo['name'] in ('params', 'log'): continue # params and log already processed
atype = None
adef = None
if 'type' in ainfo:
atype = ainfo['type']
if atype not in ('infont', 'outfont', 'infile', 'outfile', 'incsv', 'filename', 'optiondict'):
logger.log("Invalid type of " + atype + " supplied in argspec", "X")
if atype != 'optiondict': # All other types are file types, so adef must be set, even if just to ""
adef = ainfo['def'] if 'def' in ainfo else ""
if adef is None and aval is None: # If def explicitly set to None then this is optional
setattr(args, ainfo['name'], None)
continue
if c == 0:
if aval is None : logger.log("Invalid first positional parameter spec", "X")
if aval[-1] in ("\\","/"): aval = aval[0:-1] # Remove trailing slashes
else: #Handle defaults for all but first positional parameter
if adef is not None:
if not aval: aval = ""
# if aval == "" and adef == "": # Only valid for output font parameter
# if atype != "outfont":
# logger.log("No value suppiled for " + ainfo['name'], "S")
# ## Not sure why this needs to fail - we need to cope with other optional file or filename parameters
(apath, abase, aext) = splitfn(aval)
(dpath, dbase, dext) = splitfn(adef) # dpath should be None
if not apath:
if abase and aext: # If both specified then use cwd, ie no path
apath = ""
else:
apath = fppath
if not abase:
if dbase == "":
abase = fpbase
elif dbase[0] == "_": # Append to font name if starts with _
abase = fpbase + dbase
else:
abase = dbase
if not aext:
if dext:
aext = dext
elif (atype == 'outfont' or atype == 'infont'): aext = fpext
aval = os.path.join(apath, abase+aext)
# Open files/fonts
if atype == 'infont':
if tool is None:
logger.log("Can't specify a font without a font tool", "X")
infontlist.append((ainfo['name'], aval)) # Build list of fonts to open when other args processed
elif atype == 'infile':
logger.log('Opening file for input: '+aval, "P")
try:
aval = open(aval, "r", encoding="utf-8")
except Exception as e:
print(e)
sys.exit(1)
elif atype == 'incsv':
logger.log('Opening file for input: '+aval, "P")
aval = csvreader(aval, logger=logger)
elif atype == 'outfile':
(aval, path, exists) = fullpath(aval)
if not exists:
logger.log("Output file directory " + path + " does not exist", "S")
logger.log('Opening file for output: ' + aval, "P")
try:
aval = open(aval, 'w', encoding="utf-8")
except Exception as e:
print(e)
sys.exit(1)
elif atype == 'outfont':
if tool is None:
logger.log("Can't specify a font without a font tool", "X")
outfont = aval
outfontpath = apath
outfontbase = abase
outfontext = aext
elif atype == 'optiondict': # Turn multiple options in the form ['opt1=a', 'opt2=b'] into a dictionary
avaldict={}
if aval is not None:
for option in aval:
x = option.split("=", 1)
if len(x) != 2:
logger.log("options must be of the form 'param=value'", "S")
if x[1] == "\\t": x[1] = "\t" # Special handling for tab characters
avaldict[x[0]] = x[1]
aval = avaldict
setattr(args, ainfo['name'], aval)
# Open fonts - needs to be done after processing other arguments so logger and params are defined
for name, aval in infontlist:
if chain and name == 'ifont':
aval = chain["font"]
else:
if tool == "UFO": aval = Ufont(aval, params=params)
if tool == "FT" : aval = ttLib.TTFont(aval)
if tool == "FP" : aval = OpenFont(aval)
setattr(args, name, aval) # Assign the font object to args attribute
# All arguments processed, now call the main function
setattr(args, "paramsobj", params)
setattr(args, "cmdlineargs", argv)
newfont = fn(args)
# If an output font is expected and one is returned, output the font
if chainfirst: chain = True # Special handling for first call of chaining
if newfont:
if chain: # return font to be handled by chain()
return (args, newfont)
else:
if outfont:
# Backup the font if output is overwriting original input font
if outfont == infontlist[0][1]:
backupdir = os.path.join(outfontpath, execparams['backupdir'])
backupmax = int(execparams['backupkeep'])
backup = str2bool(execparams['backup'])
if backup:
if not os.path.isdir(backupdir): # Create backup directory if not present
try:
os.mkdir(backupdir)
except Exception as e:
print(e)
sys.exit(1)
backupbase = os.path.join(backupdir, outfontbase+outfontext)
# Work out backup name based on existing backups
nums = sorted([int(i[len(backupbase)+1-len(i):-1]) for i in glob(backupbase+".*~")]) # Extract list of backup numbers from existing backups
newnum = max(nums)+1 if nums else 1
backupname = backupbase+"."+str(newnum)+"~"
# Backup the font
logger.log("Backing up input font to "+backupname, "P")
shutil.copytree(outfont, backupname)
# Purge old backups
for i in range(0, len(nums) - backupmax + 1):
backupname = backupbase+"."+str(nums[i])+"~"
logger.log("Purging old backup "+backupname, "I")
shutil.rmtree(backupname)
else:
logger.log("No font backup done due to backup parameter setting", "I")
# Output the font
if tool in ("FT", "FP"):
logger.log("Saving font to " + outfont, "P")
newfont.save(outfont)
else: # Must be Pyslifont Ufont
newfont.write(outfont)
else:
logger.log("Font returned to execute() but no output font is specified in arg spec", "X")
elif chain: # ) When chaining return just args - the font can be accessed by args.ifont
return (args, None) # ) assuming that the script has not changed the input font
if logger.errorcount or logger.warningcount:
message = "Command completed with " + str(logger.errorcount) + " errors and " + str(logger.warningcount) + " warnings"
if logger.scrlevel in ("S", "E") and logname != "":
if logger.scrlevel == "S" or logger.warningcount: message = message + " - see " + logname
if logger.errorcount:
if quiet: logger.raisescrlevel("E")
logger.log(message, "E")
logger.resetscrlevel()
else:
logger.log(message, "P")
if logger.scrlevel == "P" and logger.warningcount: logger.log("See log file for warning messages or rerun with '-p scrlevel=w'", "P")
else:
logger.log("Command completed with no warnings", "P")
return (args, newfont)
def chain(argv, function, argspec, font, params, logger, quiet): # Chain multiple command-line scripts using UFO module together without writing font to disk
''' argv is a command-line call to a script in sys.argv format. function and argspec are from the script being called.
Although input font name must be supplied for the command line to be parsed correctly by execute() it is not used - instead the supplied
font object is used. Similarly -params, logfile and quiet settings in argv are not used by execute() when chaining is used'''
if quiet and "-q" not in argv: argv.append("-q")
logger.log("Chaining to " + argv[0], "P")
font = execute("UFO", function, argspec,
{'argv' : argv,
'font' : font,
'params': params,
'logger': logger,
'quiet' : quiet})
logger.log("Returning from " + argv[0], "P")
return font
def splitfn(fn): # Split filename into path, base and extension
if fn: # Remove trailing slashes
if fn[-1] in ("\\","/"): fn = fn[0:-1]
(path, base) = os.path.split(fn)
(base, ext) = os.path.splitext(base)
# Handle special case where just a directory is supplied
if ext == "": # If there's an extension, treat as file name, eg a ufo directory
if os.path.isdir(fn):
path = fn
base = ""
return (path, base, ext)
def str2bool(v): # If v is not a boolean, convert from string to boolean
if type(v) == bool: return v
v = v.lower()
if v in ("yes", "y", "true", "t", "1"):
v = True
elif v in ("no", "n", "false", "f", "0"):
v = False
else:
v = None
return v
def fullpath(filen): # Changes file name to one with full path and checks directory exists
fullname = os.path.abspath(filen)
(fpath,dummy) = os.path.split(fullname)
return fullname, fpath, os.path.isdir(fpath)
|