summaryrefslogtreecommitdiffstats
path: root/lib/silfont/scripts/psfrenameglyphs.py
blob: ff8ef7348f50df3b9ddae02489685e3cf3e047f9 (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
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
#!/usr/bin/env python
__doc__ = '''Assign new working names to glyphs based on csv input file
- csv format oldname,newname'''
__url__ = 'http://github.com/silnrsi/pysilfont'
__copyright__ = 'Copyright (c) 2017 SIL International (http://www.sil.org)'
__license__ = 'Released under the MIT License (http://opensource.org/licenses/MIT)'
__author__ = 'Bob Hallissy'

from silfont.core import execute
from xml.etree import ElementTree as ET
import re
import os
from glob import glob

argspec = [
    ('ifont',{'help': 'Input font file'}, {'type': 'infont'}),
    ('ofont',{'help': 'Output font file','nargs': '?' }, {'type': 'outfont'}),
    ('-c', '--classfile', {'help': 'Classes file'}, {}),
    ('-i','--input',{'help': 'Input csv file'}, {'type': 'incsv', 'def': 'namemap.csv'}),
    ('--mergecomps',{'help': 'turn on component merge', 'action': 'store_true', 'default': False},{}),
    ('-l','--log',{'help': 'Log file'}, {'type': 'outfile', 'def': '_renameglyphs.log'})]

csvmap = "" # Variable used globally

def doit(args) :
    global csvmap, ksetsbymember
    font = args.ifont
    incsv = args.input
    logger = args.logger
    mergemode = args.mergecomps

    failerrors = 0 # Keep count of errors that should cause the script to fail
    csvmap = {}    # List of all real maps in incsv, so excluding headers, blank lines, comments and identity maps
    nameMap = {}   # remember all glyphs actually renamed
    kerngroupsrenamed = {} # List of all kern groups actually renamed

    # List of secondary layers (ie layers other than the default)
    secondarylayers = [x for x in font.layers if x.layername != "public.default"]

    # Obtain lib.plist glyph order(s) and psnames if they exist:
    publicGlyphOrder = csGlyphOrder = psnames = displayStrings = None
    if hasattr(font, 'lib'):
        if 'public.glyphOrder' in font.lib:
            publicGlyphOrder = font.lib.getval('public.glyphOrder')     # This is an array
        if 'com.schriftgestaltung.glyphOrder' in font.lib:
            csGlyphOrder = font.lib.getval('com.schriftgestaltung.glyphOrder') # This is an array
        if 'public.postscriptNames' in font.lib:
            psnames = font.lib.getval('public.postscriptNames')   # This is a dict keyed by glyphnames
        if 'com.schriftgestaltung.customParameter.GSFont.DisplayStrings' in font.lib:
            displayStrings = font.lib.getval('com.schriftgestaltung.customParameter.GSFont.DisplayStrings')
    else:
        logger.log("no lib.plist found in font", "W")

    # Renaming within the UFO is done in two passes to make sure we can handle circular renames such as:
    #    someglyph.alt = someglyph
    #    someglyph = someglyph.alt

    # Note that the various objects with glyph names are all done independently since
    # the same glyph names are not necessarily in all structures.

    # First pass: process all records of csv, and for each glyph that is to be renamed:
    #   If the new glyphname is not already present, go ahead and rename it now.
    #   If the new glyph name already exists, rename the glyph to a temporary name
    #      and put relevant details in saveforlater[]

    saveforlaterFont = []   # For the font itself
    saveforlaterPGO = []    # For public.GlyphOrder
    saveforlaterCSGO = []   # For GlyphsApp GlyphOrder (com.schriftgestaltung.glyphOrder)
    saveforlaterPSN = []    # For public.postscriptNames
    deletelater = []        # Glyphs we'll delete after merging

    for r in incsv:
        oldname = r[0].strip()
        newname = r[1].strip()
        # ignore header row and rows where the newname is blank or a comment marker
        if oldname == "Name" or oldname.startswith('#') or newname == "" or oldname == newname:
            continue
        if len(oldname)==0:
            logger.log('empty glyph oldname in glyph_data; ignored (newname: %s)' % newname, 'W')
            continue
        csvmap[oldname]=newname

        # Handle font first:
        if oldname not in font.deflayer:
            logger.log("glyph name not in font: " + oldname , "I")
        elif newname not in font.deflayer:
            inseclayers = False
            for layer in secondarylayers:
                if newname in layer:
                    logger.log("Glyph %s is already in non-default layers; can't rename %s" % (newname, oldname), "E")
                    failerrors += 1
                    inseclayers = True
                    continue
            if not inseclayers:
                # Ok, this case is easy: just rename the glyph in all layers
                for layer in font.layers:
                    if oldname in layer: layer[oldname].name = newname
                nameMap[oldname] = newname
                logger.log("Pass 1 (Font): Renamed %s to %s" % (oldname, newname), "I")
        elif mergemode:
            mergeglyphs(font.deflayer[oldname], font.deflayer[newname])
            for layer in secondarylayers:
                if oldname in layer:
                    if newname in layer:
                        mergeglyphs(layer[oldname], layer[newname])
                    else:
                        layer[oldname].name = newname

            nameMap[oldname] = newname
            deletelater.append(oldname)
            logger.log("Pass 1 (Font): merged %s to %s" % (oldname, newname), "I")
        else:
            # newname already in font -- but it might get renamed later in which case this isn't actually a problem.
            # For now, then, rename glyph to a temporary name and remember it for second pass
            tempname = gettempname(lambda n : n not in font.deflayer)
            for layer in font.layers:
                if oldname in layer:
                    layer[oldname].name = tempname
            saveforlaterFont.append( (tempname, oldname, newname) )

        # Similar algorithm for public.glyphOrder, if present:
        if publicGlyphOrder:
            if oldname not in publicGlyphOrder:
                logger.log("glyph name not in publicGlyphorder: " + oldname , "I")
            else:
                x = publicGlyphOrder.index(oldname)
                if newname not in publicGlyphOrder:
                    publicGlyphOrder[x] = newname
                    nameMap[oldname] = newname
                    logger.log("Pass 1 (PGO): Renamed %s to %s" % (oldname, newname), "I")
                elif mergemode:
                    del publicGlyphOrder[x]
                    nameMap[oldname] = newname
                    logger.log("Pass 1 (PGO): Removed %s (now using %s)" % (oldname, newname), "I")
                else:
                    tempname = gettempname(lambda n : n not in publicGlyphOrder)
                    publicGlyphOrder[x] = tempname
                    saveforlaterPGO.append( (x, oldname, newname) )

        # And for GlyphsApp glyph order, if present:
        if csGlyphOrder:
            if oldname not in csGlyphOrder:
                logger.log("glyph name not in csGlyphorder: " + oldname , "I")
            else:
                x = csGlyphOrder.index(oldname)
                if newname not in csGlyphOrder:
                    csGlyphOrder[x] = newname
                    nameMap[oldname] = newname
                    logger.log("Pass 1 (csGO): Renamed %s to %s" % (oldname, newname), "I")
                elif mergemode:
                    del csGlyphOrder[x]
                    nameMap[oldname] = newname
                    logger.log("Pass 1 (csGO): Removed %s (now using %s)" % (oldname, newname), "I")
                else:
                    tempname = gettempname(lambda n : n not in csGlyphOrder)
                    csGlyphOrder[x] = tempname
                    saveforlaterCSGO.append( (x, oldname, newname) )

        # And for psnames
        if psnames:
            if oldname not in psnames:
                logger.log("glyph name not in psnames: " + oldname , "I")
            elif newname not in psnames:
                psnames[newname] = psnames.pop(oldname)
                nameMap[oldname] = newname
                logger.log("Pass 1 (psn): Renamed %s to %s" % (oldname, newname), "I")
            elif mergemode:
                del psnames[oldname]
                nameMap[oldname] = newname
                logger.log("Pass 1 (psn): Removed %s (now using %s)" % (oldname, newname), "I")
            else:
                tempname = gettempname(lambda n: n not in psnames)
                psnames[tempname] = psnames.pop(oldname)
                saveforlaterPSN.append( (tempname, oldname, newname))

    # Second pass: now we can reprocess those things we saved for later:
    #    If the new glyphname is no longer present, we can complete the renaming
    #    Otherwise we've got a fatal error

    for j in saveforlaterFont:
        tempname, oldname, newname = j
        if newname in font.deflayer: # Only need to check deflayer, since (if present) it would have been renamed in all
            # Ok, this really is a problem
            logger.log("Glyph %s already in font; can't rename %s" % (newname, oldname), "E")
            failerrors += 1
        else:
            for layer in font.layers:
                if tempname in layer:
                    layer[tempname].name = newname
            nameMap[oldname] = newname
            logger.log("Pass 2 (Font): Renamed %s to %s" % (oldname, newname), "I")

    for j in saveforlaterPGO:
        x, oldname, newname = j
        if newname in publicGlyphOrder:
            # Ok, this really is a problem
            logger.log("Glyph %s already in public.GlyphOrder; can't rename %s" % (newname, oldname), "E")
            failerrors += 1
        else:
            publicGlyphOrder[x] = newname
            nameMap[oldname] = newname
            logger.log("Pass 2 (PGO): Renamed %s to %s" % (oldname, newname), "I")

    for j in saveforlaterCSGO:
        x, oldname, newname = j
        if newname in csGlyphOrder:
            # Ok, this really is a problem
            logger.log("Glyph %s already in com.schriftgestaltung.glyphOrder; can't rename %s" % (newname, oldname), "E")
            failerrors += 1
        else:
            csGlyphOrder[x] = newname
            nameMap[oldname] = newname
            logger.log("Pass 2 (csGO): Renamed %s to %s" % (oldname, newname), "I")

    for tempname, oldname, newname in saveforlaterPSN:
        if newname in psnames:
            # Ok, this really is a problem
            logger.log("Glyph %s already in public.postscriptNames; can't rename %s" % (newname, oldname), "E")
            failerrors += 1
        else:
            psnames[newname] = psnames.pop(tempname)
            nameMap[oldname] = newname
            logger.log("Pass 2 (psn): Renamed %s to %s" % (oldname, newname), "I")

    # Rebuild font structures from the modified lists we have:

    # Rebuild glyph order elements:
    if publicGlyphOrder:
        array = ET.Element("array")
        for name in publicGlyphOrder:
            ET.SubElement(array, "string").text = name
        font.lib.setelem("public.glyphOrder", array)

    if csGlyphOrder:
        array = ET.Element("array")
        for name in csGlyphOrder:
            ET.SubElement(array, "string").text = name
        font.lib.setelem("com.schriftgestaltung.glyphOrder", array)

    # Rebuild postscriptNames:
    if psnames:
        dict = ET.Element("dict")
        for n in psnames:
            ET.SubElement(dict, "key").text = n
            ET.SubElement(dict, "string").text = psnames[n]
        font.lib.setelem("public.postscriptNames", dict)

    # Iterate over all glyphs, and fix up any components that reference renamed glyphs
    for layer in font.layers:
        for name in layer:
            glyph = layer[name]
            for component in glyph.etree.findall('./outline/component[@base]'):
                oldname = component.get('base')
                if oldname in nameMap:
                    component.set('base', nameMap[oldname])
                    logger.log(f'renamed component base {oldname} to {component.get("base")} in glyph {name} layer {layer.layername}', 'I')
            lib = glyph['lib']
            if lib:
                if 'com.schriftgestaltung.Glyphs.ComponentInfo' in lib:
                    cielem = lib['com.schriftgestaltung.Glyphs.ComponentInfo'][1]
                    for component in cielem:
                        for i in range(0,len(component),2):
                            if component[i].text == 'name':
                                oldname = component[i+1].text
                                if oldname in nameMap:
                                    component[i+1].text = nameMap[oldname]
                                    logger.log(f'renamed component info {oldname} to {nameMap[oldname]} in glyph {name} layer {layer.layername}', 'I')

    # Delete anything we no longer need:
    for name in deletelater:
        for layer in font.layers:
            if name in layer: layer.delGlyph(name)
        logger.log("glyph %s removed" % name, "I")

    # Other structures with glyphs in are handled by looping round the structures replacing glyphs rather than
    # looping round incsv

    # Update Display Strings

    if displayStrings:
        changed = False
        glyphRE = re.compile(r'/([a-zA-Z0-9_.-]+)') # regex to match / followed by a glyph name
        for i, dispstr in enumerate(displayStrings):            # Passing the glyphSub function to .sub() causes it to
            displayStrings[i] = glyphRE.sub(glyphsub, dispstr)  # every non-overlapping occurrence of pattern
            if displayStrings[i] != dispstr:
                changed = True
        if changed:
            array = ET.Element("array")
            for dispstr in displayStrings:
                ET.SubElement(array, "string").text = dispstr
            font.lib.setelem('com.schriftgestaltung.customParameter.GSFont.DisplayStrings', array)
            logger.log("com.schriftgestaltung.customParameter.GSFont.DisplayStrings updated", "I")

    # Process groups.plist and kerning.plist
    #   group names in the form public.kern[1|2].<glyph name> will automatically be renamed if the glyph name is in the csvmap
    #
    groups = kerning = None
    kgroupprefixes = {"public.kern1.": 1, "public.kern2.": 2}

    if "groups" in font.__dict__: groups = font.groups
    if "kerning" in font.__dict__: kerning = font.kerning

    if (groups or kerning) and mergemode:
        logger.log("Note - Kerning and group data not processed when using mergecomps", "P")
    elif groups or kerning:

        kgroupsmap = ["", {}, {}]  # Dicts of kern1/kern2 group renames.  Outside the groups if statement, since also used with kerning.plist
        if groups:
            # Analyse existing data, building dict from existing data and building some indexes
            gdict = {}
            kgroupsbyglyph =   ["", {}, {}]  # First entry dummy, so index is 1 or 2 for kern1 and kern2
            kgroupduplicates = ["", [], []]  #
            for gname in groups:
                group = groups.getval(gname)
                gdict[gname] = group
                kprefix = gname[0:13]
                if kprefix in kgroupprefixes:
                    ktype = kgroupprefixes[kprefix]
                    for glyph in group:
                        if glyph in kgroupsbyglyph[ktype]:
                            kgroupduplicates[ktype].append(glyph)
                            logger.log("In existing kern groups, %s is in more than one kern%s group" % (glyph, str(ktype)), "E")
                            failerrors += 1
                        else:
                            kgroupsbyglyph[ktype][glyph] = gname
            # Now process the group data
            glyphsrenamed = []
            saveforlaterKgroups = []
            for gname in list(gdict): # Loop round groups renaming glyphs within groups and  kern group names
                group = gdict[gname]

                # Rename group if kern1 or kern2 group
                kprefix = gname[:13]
                if kprefix in kgroupprefixes:
                    ktype = kgroupprefixes[kprefix]
                    ksuffix = gname[13:]
                    if ksuffix in csvmap: # This is a kern group that we should rename
                        newgname = kprefix + csvmap[ksuffix]
                        if newgname in gdict: # Will need to be renamed in second pass
                            tempname = gettempname(lambda n : n not in gdict)
                            gdict[tempname] = gdict.pop(gname)
                            saveforlaterKgroups.append((tempname, gname, newgname))
                        else:
                            gdict[newgname] = gdict.pop(gname)
                            kerngroupsrenamed[gname] = newgname
                            logger.log("Pass 1 (Kern groups): Renamed %s to %s" % (gname, newgname), "I")
                        kgroupsmap[ktype][gname] = newgname

                # Now rename glyphs within the group
                # - This could lead to duplicate names, but that might be valid for arbitrary groups so not checked
                # - kern group validity will be checked after all renaming is done

                for (i, glyph) in enumerate(group):
                    if glyph in csvmap:
                        group[i] = csvmap[glyph]
                        if glyph not in glyphsrenamed: glyphsrenamed.append(glyph)

            # Need to report glyphs renamed after the loop, since otherwise could report multiple times
            for oldname in glyphsrenamed:
                nameMap[oldname] = csvmap[oldname]
                logger.log("Glyphs in groups: Renamed %s to %s" % (oldname, csvmap[oldname]), "I")

            # Second pass for renaming kern groups. (All glyph renaming is done in first pass)

            for (tempname, oldgname, newgname) in saveforlaterKgroups:
                if newgname in gdict: # Can't rename
                    logger.log("Kern group %s already in groups.plist; can't rename %s" % (newgname, oldgname), "E")
                    failerrors += 1
                else:
                    gdict[newgname] = gdict.pop(tempname)
                    kerngroupsrenamed[oldgname] = newgname
                    logger.log("Pass 2 (Kern groups): Renamed %s to %s" % (oldgname, newgname), "I")

            # Finally check kern groups follow the UFO rules!
            kgroupsbyglyph = ["", {}, {}] # Reset for new analysis
            for gname in gdict:
                group = gdict[gname]
                kprefix = gname[:13]
                if kprefix in kgroupprefixes:
                    ktype = kgroupprefixes[kprefix]
                    for glyph in group:
                        if glyph in kgroupsbyglyph[ktype]: # Glyph already in a kern group so we have a duplicate
                            if glyph not in kgroupduplicates[ktype]: # This is a newly-created duplicate so report
                                logger.log("After renaming, %s is in more than one kern%s group" % (glyph, str(ktype)), "E")
                                failerrors += 1
                                kgroupduplicates[ktype].append(glyph)
                        else:
                            kgroupsbyglyph[ktype][glyph] = gname

        # Now need to recreate groups.plist from gdict

            for group in list(groups): groups.remove(group) # Empty existing contents
            for gname in gdict:
                elem = ET.Element("array")
                for glyph in gdict[gname]:
                    ET.SubElement(elem, "string").text = glyph
                groups.setelem(gname, elem)

        # Now process kerning data
        if kerning:
            k1map = kgroupsmap[1]
            k2map = kgroupsmap[2]
            kdict = {}
            for setname in kerning: kdict[setname] = kerning.getval(setname) # Create a working dict from plist
            saveforlaterKsets = []
            # First pass on set names
            for setname in list(kdict): # setname could be a glyph in csvmap or a kern1 group name in k1map
                if setname in csvmap or setname in k1map:
                    newname = csvmap[setname] if setname in csvmap else k1map[setname]
                    if newname in kdict:
                        tempname = gettempname(lambda n : n not in kdict)
                        kdict[tempname] = kdict.pop(setname)
                        saveforlaterKsets.append((tempname, setname, newname))
                    else:
                        kdict[newname] = kdict.pop(setname)
                        if setname in csvmap: nameMap[setname] = newname # Change to kern set name will have been logged previously
                        logger.log("Pass 1 (Kern sets): Renamed %s to %s" % (setname, newname), "I")

            # Now do second pass for set names
            for (tempname, oldname, newname) in saveforlaterKsets:
                if newname in kdict:  # Can't rename
                    logger.log("Kern set %s already in kerning.plist; can't rename %s" % (newname, oldname), "E")
                    failerrors += 1
                else:
                    kdict[newname] = kdict.pop(tempname)
                    if oldname in csvmap: nameMap[oldname] = newname
                    logger.log("Pass 1 (Kern sets): Renamed %s to %s" % (oldname, newname), "I")

            # Rename kern set members next.

            # Here, since a member could be in more than one set, take different approach to two passes.
            # - In first pass, rename to a temp (and invalid) name so duplicates are not possible.  Name to include
            #   old name for reporting purposes
            # - In second pass, set to correct new name after checking for duplicates

            # Do first pass for set names
            tempnames = []
            for setname in list(kdict):
                kset = kdict[setname]

                for mname in list(kset): # mname could be a glyph in csvmap or a kern2 group name in k2map
                    if mname in csvmap or mname in k2map:
                        newname = csvmap[mname] if mname in csvmap else k2map[mname]
                        newname = "^" + newname + "^" + mname
                        if newname not in tempnames: tempnames.append(newname)
                        kset[newname] = kset.pop(mname)

            # Second pass to change temp names to correct final names
            # We need an index of which sets each member is in
            ksetsbymember = {}
            for setname in kdict:
                kset = kdict[setname]
                for member in kset:
                    if member not in ksetsbymember:
                        ksetsbymember[member] = [setname]
                    else:
                        ksetsbymember[member].append(setname)
            # Now do the renaming
            for tname in tempnames:
                (newname, oldname) = tname[1:].split("^")
                if newname in ksetsbymember:  # Can't rename
                    logger.log("Kern set %s already in kerning.plist; can't rename %s" % (newname, oldname), "E")
                    failerrors += 1
                else:
                    for ksetname in ksetsbymember[tname]:
                        kset = kdict[ksetname]
                        kset[newname] = kset.pop(tname)
                    ksetsbymember[newname] = ksetsbymember.pop(tname)
                    if tname in csvmap: nameMap[oldname] = newname
                    logger.log("Kern set members: Renamed %s to %s" % (oldname, newname), "I")

            # Now need to recreate kerning.plist from kdict
            for kset in list(kerning): kerning.remove(kset)  # Empty existing contents
            for kset in kdict:
                elem = ET.Element("dict")
                for member in kdict[kset]:
                    ET.SubElement(elem, "key").text = member
                    ET.SubElement(elem, "integer").text = str(kdict[kset][member])
                kerning.setelem(kset, elem)

    if failerrors:
        logger.log(str(failerrors) + " issues detected - see errors reported above", "S")

    logger.log("%d glyphs renamed in UFO" % (len(nameMap)), "P")
    if kerngroupsrenamed: logger.log("%d kern groups renamed in UFO" % (len(kerngroupsrenamed)), "P")

    # If a classfile was provided, change names within it also
    #
    if args.classfile:

        logger.log("Processing classfile {}".format(args.classfile), "P")

        # In order to preserve comments we use our own TreeBuilder
        class MyTreeBuilder(ET.TreeBuilder):
            def comment(self, data):
                self.start(ET.Comment, {})
                self.data(data)
                self.end(ET.Comment)

        # RE to match separators between glyph names (whitespace):
        notGlyphnameRE = re.compile(r'(\s+)')

        # Keep a list of glyphnames that were / were not changed
        changed = set()
        notChanged = set()

        # Process one token (might be whitespace separator, glyph name, or embedded classname starting with @):
        def dochange(gname, logErrors = True):
            if len(gname) == 0 or gname.isspace() or gname not in csvmap or gname.startswith('@'):
                # No change
                return gname
            try:
                newgname = csvmap[gname]
                changed.add(gname)
                return newgname
            except KeyError:
                if logErrors: notChanged.add(gname)
                return gname

        doc = ET.parse(args.classfile, parser=ET.XMLParser(target=MyTreeBuilder()))
        for e in doc.iter(None):
            if e.tag in ('class', 'property'):
                if 'exts' in e.attrib:
                    logger.log("{} '{}' has 'exts' attribute which may need editing".format(e.tag.title(), e.get('name')), "W")
                # Rather than just split() the text, we'll use re and thus try to preserve whitespace
                e.text = ''.join([dochange(x) for x in notGlyphnameRE.split(e.text)])
            elif e.tag is ET.Comment:
                # Go ahead and look for glyph names in comment text but don't flag as error
                e.text = ''.join([dochange(x, False) for x in notGlyphnameRE.split(e.text)])
                # and process the tail as this might be valid part of class or property
                e.tail = ''.join([dochange(x) for x in notGlyphnameRE.split(e.tail)])


        if len(changed):
            # Something in classes changed so rewrite it... saving  backup
            (dn,fn) = os.path.split(args.classfile)
            dn = os.path.join(dn, args.paramsobj.sets['main']['backupdir'])
            if not os.path.isdir(dn):
                os.makedirs(dn)
            # Work out backup name based on existing backups
            backupname = os.path.join(dn,fn)
            nums = [int(re.search(r'\.(\d+)~$',n).group(1)) for n in glob(backupname + ".*~")]
            backupname += ".{}~".format(max(nums) + 1 if nums else 1)
            logger.log("Backing up input classfile to {}".format(backupname), "P")
            # Move the original file to backupname
            os.rename(args.classfile, backupname)
            # Write the output file
            doc.write(args.classfile)

            if len(notChanged):
                logger.log("{} glyphs renamed, {} NOT renamed in {}: {}".format(len(changed), len(notChanged), args.classfile, ' '.join(notChanged)), "W")
            else:
                logger.log("All {} glyphs renamed in {}".format(len(changed), args.classfile), "P")

    return font

def mergeglyphs(mergefrom, mergeto): # Merge any "moving" anchors (i.e., those starting with '_') into the glyph we're keeping
    # Assumption: we are merging one or more component references to just one component; deleting the others
    for a in mergefrom['anchor']:
        aname = a.element.get('name')
        if aname.startswith('_'):
            # We want to copy this anchor to the glyph being kept:
            for i, a2 in enumerate(mergeto['anchor']):
                if a2.element.get('name') == aname:
                    # Overwrite existing anchor of same name
                    mergeto['anchor'][i] = a
                    break
            else:
                # Append anchor to glyph
                mergeto['anchor'].append(a)

def gettempname(f):
    ''' return a temporary glyph name that, when passed to function f(), returns true'''
    # Initialize function attribute for use as counter
    if not hasattr(gettempname, "counter"): gettempname.counter = 0
    while True:
        name = "tempglyph%d" % gettempname.counter
        gettempname.counter += 1
        if f(name): return name

def glyphsub(m): # Function passed to re.sub() when updating display strings
    global csvmap
    gname = m.group(1)
    return '/' + csvmap[gname] if gname in csvmap else m.group(0)

def cmd() : execute("UFO",doit,argspec)
if __name__ == "__main__": cmd()