-
Notifications
You must be signed in to change notification settings - Fork 19
/
Copy pathufonormalizer.py
1766 lines (1588 loc) · 55.5 KB
/
ufonormalizer.py
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
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#! /usr/bin/env python
# -*- coding: utf-8 -*-
from __future__ import print_function, unicode_literals
import time
import os
import shutil
from xml.etree import cElementTree as ET
import plistlib
import textwrap
import datetime
import glob
from collections import OrderedDict
from io import open
import logging
"""
- filter out unknown attributes and subelements
- add doctests for the image purging
- things that need to be improved are marked with "# TO DO"
"""
__version__ = "0.4.1"
description = """
UFO Normalizer (version %s):
This tool processes the contents of a UFO and normalizes
all possible files to a standard XML formatting, data
structure and file naming scheme.
""" % __version__
log = logging.getLogger(__name__)
def main(args=None):
import argparse
parser = argparse.ArgumentParser(description=description)
parser.add_argument("input", help="Path to a UFO to normalize.", nargs="?")
parser.add_argument("-t", "--test", help="Run the normalizer's internal tests.", action="store_true")
parser.add_argument("-o", "--output", help="Output path. If not given, the input path will be used.")
parser.add_argument("-a", "--all", help="Normalize all files in the UFO. By default, only files modified since the previous normalization will be processed.", action="store_true")
parser.add_argument("-v", "--verbose", help="Print more info to console.", action="store_true")
parser.add_argument("-q", "--quiet", help="Suppress all non-error messages.", action="store_true")
parser.add_argument("--float-precision", type=int, default=DEFAULT_FLOAT_PRECISION, help="Round floats to the specified number of decimal places (default is %d). The value -1 means no rounding (i.e. use built-in repr()." % DEFAULT_FLOAT_PRECISION)
parser.add_argument("-m", "--no-mod-times", help="Do not write normalization time stamps.", action="store_true")
args = parser.parse_args(args)
if args.test:
return runTests()
if args.verbose and args.quiet:
parser.error("--quiet and --verbose options are mutually exclusive.")
logLevel = "DEBUG" if args.verbose else "ERROR" if args.quiet else "INFO"
logging.basicConfig(level=logLevel, format="%(message)s")
if args.input is None:
parser.error("No input path was specified.")
inputPath = os.path.normpath(args.input)
outputPath = args.output
onlyModified = not args.all
if not os.path.exists(inputPath):
parser.error('Input path does not exist: "%s".' % inputPath)
if os.path.splitext(inputPath)[-1].lower() != ".ufo":
parser.error('Input path is not a UFO: "%s".' % inputPath)
if args.float_precision >= 0:
floatPrecision = args.float_precision
elif args.float_precision == -1:
floatPrecision = None
else:
parser.error("float precision must be >= 0 or -1 (no round).")
writeModTimes = not args.no_mod_times
message = 'Normalizing "%s".'
if not onlyModified:
message += " Processing all files."
log.info(message, os.path.basename(inputPath))
start = time.time()
normalizeUFO(inputPath, outputPath=outputPath, onlyModified=onlyModified,
floatPrecision=floatPrecision, writeModTimes=writeModTimes)
runtime = time.time() - start
log.info("Normalization complete (%.4f seconds).", runtime)
# ---------
# Internals
# ---------
modTimeLibKey = "org.unifiedfontobject.normalizer.modTimes"
imageReferencesLibKey = "org.unifiedfontobject.normalizer.imageReferences"
# Differences between Python 2 and Python 3
# Python 3 does not have long, basestring, unicode
try:
long
except NameError:
long = int
try:
basestring
except NameError:
basestring = str
try:
unicode
except NameError:
unicode = str
# Python 3.4 deprecated plistlib.readPlistFromBytes for loads.
# Python 2 does not have plistlib.readPlistFromBytes it has
# plistlib.readPlistFromString instead.
if hasattr(plistlib, "loads"):
def _loads(data):
return plistlib.loads(data, use_builtin_types=False)
def _dumps(plist):
return plistlib.dumps(plist)
elif hasattr(plistlib, "readPlistFromBytes"):
def _loads(data):
return plistlib.readPlistFromBytes(tobytes(data))
def _dumps(plist):
return plistlib.writePlistToBytes(plist)
else:
def _loads(data):
return plistlib.readPlistFromString(data)
def _dumps(plist):
return plistlib.writePlistToString(plist)
# from fontTools.misc.py23
def tobytes(s, encoding='ascii', errors='strict'):
'''no docstring'''
if not isinstance(s, bytes):
return s.encode(encoding, errors)
else:
return s
def tounicode(s, encoding='ascii', errors='strict'):
if not isinstance(s, unicode):
return s.decode(encoding, errors)
else:
return s
if str == bytes:
tostr = tobytes
else:
tostr = tounicode
class UFONormalizerError(Exception):
pass
DEFAULT_FLOAT_PRECISION = 10
FLOAT_FORMAT = "%%.%df" % DEFAULT_FLOAT_PRECISION
def normalizeUFO(ufoPath, outputPath=None, onlyModified=True,
floatPrecision=DEFAULT_FLOAT_PRECISION, writeModTimes=True):
global FLOAT_FORMAT
if floatPrecision is None:
# use repr() and don't round floats
FLOAT_FORMAT = None
else:
# round floats to a fixed number of decimal digits
FLOAT_FORMAT = "%%.%df" % floatPrecision
# if the output is going to a different location,
# duplicate the UFO to the new place and work
# on the new file instead of trying to reconstruct
# the file one piece at a time.
if outputPath is not None and outputPath != ufoPath:
duplicateUFO(ufoPath, outputPath)
ufoPath = outputPath
# get the UFO format version
if not subpathExists(ufoPath, "metainfo.plist"):
raise UFONormalizerError("Required metainfo.plist file not in %s." % ufoPath)
metaInfo = subpathReadPlist(ufoPath, "metainfo.plist")
formatVersion = metaInfo.get("formatVersion")
if formatVersion is None:
raise UFONormalizerError("Required formatVersion value not defined in in metainfo.plist in %s." % ufoPath)
try:
fV = int(formatVersion)
formatVersion = fV
except ValueError:
raise UFONormalizerError("Required formatVersion value not properly formatted in metainfo.plist in %s." % ufoPath)
if formatVersion > 3:
raise UFONormalizerError("Unsupported UFO format (%d) in %s." % (formatVersion, ufoPath))
# load the font lib
if not subpathExists(ufoPath, "lib.plist"):
fontLib = {}
else:
fontLib = subpathReadPlist(ufoPath, "lib.plist")
# get the modification times
if onlyModified:
modTimes = readModTimes(fontLib)
else:
modTimes = {}
# normalize layers
if formatVersion < 3:
if subpathExists(ufoPath, "glyphs"):
normalizeUFO1And2GlyphsDirectory(ufoPath, modTimes)
else:
availableImages = readImagesDirectory(ufoPath)
referencedImages = set()
normalizeGlyphsDirectoryNames(ufoPath)
if subpathExists(ufoPath, "layercontents.plist"):
layerContents = subpathReadPlist(ufoPath, "layercontents.plist")
for layerName, layerDirectory in layerContents:
layerReferencedImages = normalizeGlyphsDirectory(ufoPath, layerDirectory, onlyModified=onlyModified, writeModTimes=writeModTimes)
referencedImages |= layerReferencedImages
imagesToPurge = availableImages - referencedImages
purgeImagesDirectory(ufoPath, imagesToPurge)
# normalize top level files
normalizeMetaInfoPlist(ufoPath, modTimes)
if subpathExists(ufoPath, "fontinfo.plist"):
normalizeFontInfoPlist(ufoPath, modTimes)
if subpathExists(ufoPath, "groups.plist"):
normalizeGroupsPlist(ufoPath, modTimes)
if subpathExists(ufoPath, "kerning.plist"):
normalizeKerningPlist(ufoPath, modTimes)
if subpathExists(ufoPath, "layercontents.plist"):
normalizeLayerContentsPlist(ufoPath, modTimes)
# update the mod time storage, write, normalize
if writeModTimes:
storeModTimes(fontLib, modTimes)
subpathWritePlist(fontLib, ufoPath, "lib.plist")
if subpathExists(ufoPath, "lib.plist"):
normalizeLibPlist(ufoPath)
# ------
# Layers
# ------
def normalizeGlyphsDirectoryNames(ufoPath):
"""
Normalize glyphs directory names following
UFO 3 user name to file name convention.
"""
# INVALID DATA POSSIBILITY: directory for layer name may not exist
# INVALID DATA POSSIBILITY: directory may not be stored in layer contents
oldLayerMapping = OrderedDict()
if subpathExists(ufoPath, "layercontents.plist"):
layerContents = subpathReadPlist(ufoPath, "layercontents.plist")
for layerName, layerDirectory in layerContents:
oldLayerMapping[layerName] = layerDirectory
if not oldLayerMapping:
return
# INVALID DATA POSSIBILITY: no default layer
# INVALID DATA POSSIBILITY: public.default used for directory other than "glyphs"
newLayerMapping = OrderedDict()
newLayerDirectories = set()
for layerName, oldLayerDirectory in oldLayerMapping.items():
if oldLayerDirectory == "glyphs":
newLayerDirectory = "glyphs"
else:
newLayerDirectory = userNameToFileName(unicode(layerName), newLayerDirectories, prefix="glyphs.")
newLayerDirectories.add(newLayerDirectory.lower())
newLayerMapping[layerName] = newLayerDirectory
# don't do a direct rename because an old directory
# may have the same name as a new directory.
fromTempMapping = {}
for index, (layerName, newLayerDirectory) in enumerate(newLayerMapping.items()):
oldLayerDirectory = oldLayerMapping[layerName]
if newLayerDirectory == oldLayerDirectory:
continue
log.debug('Normalizing "%s" layer directory name to "%s".', layerName, newLayerDirectory)
tempDirectory = "org.unifiedfontobject.normalizer.%d" % index
subpathRenameDirectory(ufoPath, oldLayerDirectory, tempDirectory)
fromTempMapping[tempDirectory] = newLayerDirectory
for tempDirectory, newLayerDirectory in fromTempMapping.items():
subpathRenameDirectory(ufoPath, tempDirectory, newLayerDirectory)
# update layercontents.plist
newLayerMapping = list(newLayerMapping.items())
subpathWritePlist(newLayerMapping, ufoPath, "layercontents.plist")
return newLayerMapping
# ------
# Glyphs
# ------
def normalizeUFO1And2GlyphsDirectory(ufoPath, modTimes):
glyphMapping = normalizeGlyphNames(ufoPath, "glyphs")
for fileName in sorted(glyphMapping.values()):
location = subpathJoin("glyphs", fileName)
if subpathNeedsRefresh(modTimes, ufoPath, location):
log.debug('Normalizing "%s".', os.path.join("glyphs", fileName))
normalizeGLIF(ufoPath, "glyphs", fileName)
modTimes[location] = subpathGetModTime(ufoPath, "glyphs", fileName)
def normalizeGlyphsDirectory(ufoPath, layerDirectory, onlyModified=True, writeModTimes=True):
if subpathExists(ufoPath, layerDirectory, "layerinfo.plist"):
layerInfo = subpathReadPlist(ufoPath, layerDirectory, "layerinfo.plist")
else:
layerInfo = {}
layerLib = layerInfo.get("lib", {})
imageReferences = {}
if onlyModified:
stored = readImageReferences(layerLib)
if stored is not None:
imageReferences = stored
else:
# we don't know what has a reference so we must check everything
onlyModified = False
if onlyModified:
modTimes = readModTimes(layerLib)
else:
modTimes = {}
glyphMapping = normalizeGlyphNames(ufoPath, layerDirectory)
for fileName in glyphMapping.values():
if subpathNeedsRefresh(modTimes, ufoPath, layerDirectory, fileName):
imageFileName = normalizeGLIF(ufoPath, layerDirectory, fileName)
if imageFileName is not None:
imageReferences[fileName] = imageFileName
elif fileName in imageReferences:
del imageReferences[fileName]
modTimes[fileName] = subpathGetModTime(ufoPath, layerDirectory, fileName)
if writeModTimes:
storeModTimes(layerLib, modTimes)
storeImageReferences(layerLib, imageReferences)
layerInfo["lib"] = layerLib
subpathWritePlist(layerInfo, ufoPath, layerDirectory, "layerinfo.plist")
normalizeLayerInfoPlist(ufoPath, layerDirectory)
referencedImages = set(imageReferences.values())
return referencedImages
def normalizeLayerInfoPlist(ufoPath, layerDirectory):
if subpathExists(ufoPath, layerDirectory, "layerinfo.plist"):
_normalizePlistFile({}, ufoPath, *[layerDirectory, "layerinfo.plist"], preprocessor=_normalizeLayerInfoColor)
def _normalizeLayerInfoColor(obj):
"""
- Normalize the color if specified.
"""
if "color" in obj:
color = obj.pop("color")
color = _normalizeColorString(color)
if color is not None:
obj["color"] = color
def normalizeGlyphNames(ufoPath, layerDirectory):
"""
Normalize GLIF file names following
UFO 3 user name to file name convention.
"""
# INVALID DATA POSSIBILITY: no contents.plist
# INVALID DATA POSSIBILITY: file for glyph name may not exist
# INVALID DATA POSSIBILITY: file for glyph may not be stored in contents
if not subpathExists(ufoPath, layerDirectory, "contents.plist"):
return {}
oldGlyphMapping = subpathReadPlist(ufoPath, layerDirectory, "contents.plist")
newGlyphMapping = {}
newFileNames = set()
for glyphName in sorted(oldGlyphMapping.keys()):
newFileName = userNameToFileName(unicode(glyphName), newFileNames, suffix=".glif")
newFileNames.add(newFileName.lower())
newGlyphMapping[glyphName] = newFileName
# don't do a direct rewrite in case an old file has
# the same name as a new file.
fromTempMapping = {}
for index, (glyphName, newFileName) in enumerate(sorted(newGlyphMapping.items())):
oldFileName = oldGlyphMapping[glyphName]
if newFileName == oldFileName:
continue
tempFileName = "org.unifiedfontobject.normalizer.%d" % index
subpathRenameFile(ufoPath, (layerDirectory, oldFileName), (layerDirectory, tempFileName))
fromTempMapping[tempFileName] = newFileName
for tempFileName, newFileName in fromTempMapping.items():
subpathRenameFile(ufoPath, (layerDirectory, tempFileName), (layerDirectory, newFileName))
# update contents.plist
subpathWritePlist(newGlyphMapping, ufoPath, layerDirectory, "contents.plist")
# normalize contents.plist
_normalizePlistFile({}, ufoPath, layerDirectory, "contents.plist", removeEmpty=False)
return newGlyphMapping
def _test_normalizeGlyphNames(oldGlyphMapping, expectedGlyphMapping):
import tempfile
directory = tempfile.mkdtemp()
layerDirectory = "glyphs"
fullLayerDirectory = subpathJoin(directory, layerDirectory)
os.mkdir(fullLayerDirectory)
for fileName in oldGlyphMapping.values():
subpathWriteFile("", directory, layerDirectory, fileName)
assert sorted(os.listdir(fullLayerDirectory)) == sorted(oldGlyphMapping.values())
subpathWritePlist(oldGlyphMapping, directory, layerDirectory, "contents.plist")
newGlyphMapping = normalizeGlyphNames(directory, layerDirectory)
listing = os.listdir(fullLayerDirectory)
listing.remove("contents.plist")
assert sorted(listing) == sorted(newGlyphMapping.values())
assert subpathReadPlist(directory, layerDirectory, "contents.plist") == newGlyphMapping
shutil.rmtree(directory)
return newGlyphMapping == expectedGlyphMapping
# ---------------
# Top-Level Files
# ---------------
# These are broken into separate, file specific
# functions for clarity and in case file specific
# normalization (such as filtering default values)
# needs to occur.
def _normalizePlistFile(modTimes, ufoPath, *subpath, **kwargs):
if subpathNeedsRefresh(modTimes, ufoPath, *subpath):
preprocessor = kwargs.get("preprocessor")
data = subpathReadPlist(ufoPath, *subpath)
if data:
log.debug('Normalizing "%s".', os.path.join(*subpath))
text = normalizePropertyList(data, preprocessor=preprocessor)
subpathWriteFile(text, ufoPath, *subpath)
modTimes[subpath[-1]] = subpathGetModTime(ufoPath, *subpath)
elif kwargs.get("removeEmpty", True):
# Don't write empty plist files, unless 'removeEmpty' is False
log.debug('Removing empty "%s".', os.path.join(*subpath))
subpathRemoveFile(ufoPath, *subpath)
if subpath[-1] in modTimes:
del modTimes[subpath[-1]]
# metainfo.plist
def normalizeMetaInfoPlist(ufoPath, modTimes):
_normalizePlistFile(modTimes, ufoPath, "metainfo.plist", removeEmpty=False)
# fontinfo.plist
def normalizeFontInfoPlist(ufoPath, modTimes):
_normalizePlistFile(modTimes, ufoPath, "fontinfo.plist", preprocessor=_normalizeFontInfoGuidelines)
def _normalizeFontInfoGuidelines(obj):
"""
- Follow general guideline normalization rules.
"""
guidelines = obj.get("guidelines")
if not guidelines:
return
normalized = []
for guideline in guidelines:
guideline = _normalizeDictGuideline(guideline)
if guideline is not None:
normalized.append(guideline)
obj["guidelines"] = normalized
def _normalizeDictGuideline(guideline):
"""
- Don't write if angle is defined but either x or y are not defined.
- Don't write if both x and y are defined but angle is not defined.
However <x=300 y=0> or <x=0 y=300> are allowed, and the 0 becomes None.
"""
x = guideline.get("x")
y = guideline.get("y")
angle = guideline.get("angle")
name = guideline.get("name")
color = guideline.get("color")
identifier = guideline.get("identifier")
# value errors
if x is not None:
try:
x = float(x)
except ValueError:
return
if y is not None:
try:
y = float(y)
except ValueError:
return
if angle is not None:
try:
angle = float(angle)
except ValueError:
return
# The spec was ambiguous about y=0 or x=0, so don't raise an error here,
# instead, <x=300 y=0> or <x=0 y=300> are allowed, and the 0 becomes None.
if angle is None:
if x == 0:
x = None
if y == 0:
y = None
# either x or y must be defined
if x is None and y is None:
return
# if angle is specified, x and y must be specified
if (x is None or y is None) and angle is not None:
return
# if x and y are specified, angle must be specified
if (x is not None and y is not None) and angle is None:
return
normalized = {}
if x is not None:
normalized["x"] = x
if y is not None:
normalized["y"] = y
if angle is not None:
normalized["angle"] = angle
if name is not None:
normalized["name"] = name
if color is not None:
color = _normalizeColorString(color)
if color is not None:
normalized["color"] = color
if identifier is not None:
normalized["identifier"] = identifier
return normalized
# groups.plist
def normalizeGroupsPlist(ufoPath, modTimes):
_normalizePlistFile(modTimes, ufoPath, "groups.plist")
# kerning.plist
def normalizeKerningPlist(ufoPath, modTimes):
_normalizePlistFile(modTimes, ufoPath, "kerning.plist")
# layercontents.plist
def normalizeLayerContentsPlist(ufoPath, modTimes):
_normalizePlistFile(modTimes, ufoPath, "layercontents.plist", removeEmpty=False)
# lib.plist
def normalizeLibPlist(ufoPath):
_normalizePlistFile({}, ufoPath, "lib.plist")
# -----------------
# XML Normalization
# -----------------
# Property List
def normalizePropertyList(data, preprocessor=None):
if preprocessor is not None:
preprocessor(data)
writer = XMLWriter(isPropertyList=True)
writer.beginElement("plist", attrs=dict(version="1.0"))
writer.propertyListObject(data)
writer.endElement("plist")
writer.raw("")
return writer.getText()
# GLIF
def normalizeGLIFString(text, glifPath=None, imageFileRef=[]):
tree = ET.fromstring(text)
glifVersion = tree.attrib.get("format")
if glifVersion is None:
msg = "Undefined GLIF format"
if glifPath is not None:
msg += ": %s" % glifPath
raise UFONormalizerError(msg)
glifVersion = int(glifVersion)
name = tree.attrib.get("name")
# start the writer
writer = XMLWriter()
# grab the top-level elements
advance = None
unicodes = []
note = None
image = None
guidelines = []
anchors = []
outline = None
lib = None
for element in tree:
tag = element.tag
if tag == "advance":
advance = element
elif tag == "unicode":
unicodes.append(element)
elif tag == "note":
note = element
elif tag == "image":
image = element
elif tag == "guideline":
guidelines.append(element)
elif tag == "anchor":
anchors.append(element)
elif tag == "outline":
outline = element
elif tag == "lib":
lib = element
# write the data
writer.beginElement("glyph", attrs=dict(name=name, format=glifVersion))
for uni in unicodes:
_normalizeGlifUnicode(uni, writer)
if advance is not None:
_normalizeGlifAdvance(advance, writer)
if glifVersion >= 2 and image is not None:
imageFileRef[:] = image.attrib.get("fileName")
_normalizeGlifImage(image, writer)
if outline is not None:
if glifVersion == 1:
_normalizeGlifOutlineFormat1(outline, writer)
else:
_normalizeGlifOutlineFormat2(outline, writer)
if glifVersion >= 2:
for anchor in anchors:
_normalizeGlifAnchor(anchor, writer)
if glifVersion >= 2:
for guideline in guidelines:
_normalizeGlifGuideline(guideline, writer)
if lib is not None:
_normalizeGlifLib(lib, writer)
if note is not None:
_normalizeGlifNote(note, writer)
writer.endElement("glyph")
writer.raw("")
return writer.getText()
def normalizeGLIF(ufoPath, *subpath):
"""
- Normalize the mark color if specified.
TO DO: need doctests
The best way to test this is going to be have a GLIF
that contains all of the element types. This can be
round tripped and compared to make sure that the result
matches the expectations. This GLIF doesn't need to
contain a robust series of element variations as the
testing of those will be handled by the element
normalization functions.
"""
# INVALID DATA POSSIBILITY: format version that can't be converted to int
# read and parse
glifPath = subpathJoin(ufoPath, *subpath)
text = subpathReadFile(ufoPath, *subpath)
imageFileRef = []
normalizedText = normalizeGLIFString(text, glifPath, imageFileRef)
subpathWriteFile(normalizedText, ufoPath, *subpath)
# return the image reference
imageFileName = imageFileRef[0] if imageFileRef else None
return imageFileName
def _normalizeGlifUnicode(element, writer):
"""
- Don't write unicode element if hex attribute is not defined.
- Don't write unicode element if value for hex value is not a proper hex value.
- Write hex value as all uppercase, zero padded string.
"""
v = element.attrib.get("hex")
# INVALID DATA POSSIBILITY: no hex value
if v:
# INVALID DATA POSSIBILITY: invalid hex value
try:
d = int(v, 16)
v = "%04X" % d
except ValueError:
return
else:
return
writer.simpleElement("unicode", attrs=dict(hex=v))
def _normalizeGlifAdvance(element, writer):
"""
- Don't write default values (width=0, height=0)
- Ignore values that can't be converted to a number.
- Don't write an empty element.
"""
# INVALID DATA POSSIBILITY: value that can't be converted to float
w = element.attrib.get("width", "0")
h = element.attrib.get("height", "0")
try:
w = float(w)
h = float(h)
except ValueError:
return
attrs = {}
# filter out default value (0)
if w:
attrs["width"] = w
if h:
attrs["height"] = h
if not attrs:
return
writer.simpleElement("advance", attrs=attrs)
def _normalizeGlifImage(element, writer):
"""
- Don't write if fileName is not defined.
"""
# INVALID DATA POSSIBILITY: no file name defined
# INVALID DATA POSSIBILITY: non-existent file referenced
fileName = element.attrib.get("fileName")
if not fileName:
return
attrs = dict(
fileName=fileName
)
transformation = _normalizeGlifTransformation(element)
attrs.update(transformation)
color = element.attrib.get("color")
if color is not None:
attrs["color"] = _normalizeColorString(color)
writer.simpleElement("image", attrs=attrs)
def _normalizeGlifAnchor(element, writer):
"""
- Don't write if x or y are not defined.
"""
# INVALID DATA POSSIBILITY: no x defined
# INVALID DATA POSSIBILITY: no y defined
# INVALID DATA POSSIBILITY: x or y that can't be converted to float
x = element.attrib.get("x")
y = element.attrib.get("y")
# x or y undefined
if not x or not y:
return
# x or y improperly defined
try:
x = float(x)
y = float(y)
except ValueError:
return
attrs = dict(
x=x,
y=y
)
name = element.attrib.get("name")
if name is not None:
attrs["name"] = name
color = element.attrib.get("color")
if color is not None:
attrs["color"] = _normalizeColorString(color)
identifier = element.attrib.get("identifier")
if identifier is not None:
attrs["identifier"] = identifier
writer.simpleElement("anchor", attrs=attrs)
def _normalizeGlifGuideline(element, writer):
"""
- Follow general guideline normalization rules.
"""
# INVALID DATA POSSIBILITY: x, y and angle not defined according to the spec
# INVALID DATA POSSIBILITY: angle < 0 or > 360
# INVALID DATA POSSIBILITY: x, y or angle that can't be converted to float
attrs = "x y angle color name identifier".split(" ")
converted = {}
for attr in attrs:
converted[attr] = element.attrib.get(attr)
normalized = _normalizeDictGuideline(converted)
if normalized is not None:
writer.simpleElement("guideline", attrs=normalized)
def _normalizeGlifLib(element, writer):
"""
- Don't write an empty element.
"""
if not len(element):
return
obj = _convertPlistElementToObject(element[0])
if obj:
# normalize the mark color
if "public.markColor" in obj:
color = obj.pop("public.markColor")
color = _normalizeColorString(color)
if color is not None:
obj["public.markColor"] = color
writer.beginElement("lib")
writer.propertyListObject(obj)
writer.endElement("lib")
def _normalizeGlifNote(element, writer):
"""
- Don't write an empty element.
"""
value = element.text
if not value:
return
if not value.strip():
return
writer.beginElement("note")
writer.text(value)
writer.endElement("note")
def _normalizeGlifOutlineFormat1(element, writer):
"""
- Don't write an empty element.
- Don't write an empty contour.
- Don't write an empty component.
- Retain contour and component order except for implied anchors in < UFO 3.
- If the UFO format < 3, move implied anchors to the end.
"""
if not len(element):
return
outline = []
anchors = []
for subElement in element:
tag = subElement.tag
if tag == "contour":
contour = _normalizeGlifContourFormat1(subElement)
if contour is None:
continue
if contour["type"] == "contour":
outline.append(contour)
else:
anchors.append(contour)
elif tag == "component":
component = _normalizeGlifComponentFormat1(subElement)
if component is None:
continue
if component is not None:
outline.append(component)
if not outline and not anchors:
return
writer.beginElement("outline")
for obj in outline:
t = obj.pop("type")
if t == "contour":
writer.beginElement("contour")
for point in obj["points"]:
writer.simpleElement("point", attrs=point)
writer.endElement("contour")
elif t == "component":
writer.simpleElement("component", attrs=obj)
for anchor in anchors:
t = anchor.pop("type")
writer.beginElement("contour")
attrs = dict(
type="move",
x=anchor["x"],
y=anchor["y"]
)
if "name" in anchor:
attrs["name"] = anchor["name"]
writer.simpleElement("point", attrs=attrs)
writer.endElement("contour")
writer.endElement("outline")
def _normalizeGlifContourFormat1(element):
"""
- Don't write unknown subelements.
"""
# INVALID DATA POSSIBILITY: unknown child element
# INVALID DATA POSSIBILITY: unknown point type
points = []
for subElement in element:
tag = subElement.tag
if tag != "point":
continue
attrs = _normalizeGlifPointAttributesFormat1(subElement)
if not attrs:
return
points.append(attrs)
if not points:
return
# anchor
if len(points) == 1 and points[0].get("type") == "move":
anchor = points[0]
anchor["type"] = "anchor"
return anchor
# contour
contour = dict(type="contour", points=points)
return contour
def _normalizeGlifPointAttributesFormat1(element):
"""
- Don't write if x or y is undefined.
- Don't write default smooth value (no).
- Don't write smooth for offcurves.
- Don't write default point type attribute (offcurve).
- Don't write subelements.
- Don't write smooth if undefined.
- Don't write unknown point types.
"""
# INVALID DATA POSSIBILITY: no x defined
# INVALID DATA POSSIBILITY: no y defined
# INVALID DATA POSSIBILITY: x or y that can't be converted to float
# INVALID DATA POSSIBILITY: duplicate attributes
x = element.attrib.get("x")
y = element.attrib.get("y")
if not x or not y:
return {}
try:
x = float(x)
y = float(y)
except ValueError:
return
attrs = dict(
x=x,
y=y
)
typ = element.attrib.get("type", "offcurve")
if typ not in ("move", "line", "curve", "qcurve", "offcurve"):
return {}
if typ != "offcurve":
attrs["type"] = typ
smooth = element.attrib.get("smooth")
if smooth == "yes":
attrs["smooth"] = "yes"
name = element.attrib.get("name")
if name is not None:
attrs["name"] = name
return attrs
def _normalizeGlifComponentFormat1(element):
"""
- Don't write if base is undefined.
- Don't write subelements.
"""
# INVALID DATA POSSIBILITY: no base defined
# INVALID DATA POSSIBILITY: unknown child element
component = _normalizeGlifComponentAttributesFormat1(element)
if not component:
return
component["type"] = "component"
return component
def _normalizeGlifComponentAttributesFormat1(element):
"""
- Don't write if base is not defined.
- Don't write default transformation values.
"""
# INVALID DATA POSSIBILITY: no base defined
# INVALID DATA POSSIBILITY: duplicate attributes
base = element.attrib.get("base")
if not base:
return {}
attrs = dict(
base=element.attrib["base"]
)
transformation = _normalizeGlifTransformation(element)
attrs.update(transformation)
return attrs
def _normalizeGlifOutlineFormat2(element, writer):
"""
- Don't write an empty element.
- Don't write an empty contour.
- Don't write an empty component.
- Retain contour and component order.
- Don't write unknown subelements.
"""
outline = []
for subElement in element:
tag = subElement.tag
if tag == "contour":
contour = _normalizeGlifContourFormat2(subElement)
if contour:
outline.append(contour)
elif tag == "component":
component = _normalizeGlifComponentFormat2(subElement)
if component:
outline.append(component)
if not outline:
return
writer.beginElement("outline")
for obj in outline:
t = obj.pop("type")
if t == "contour":
attrs = {}
identifier = obj.get("identifier")
if identifier is not None:
attrs["identifier"] = identifier
writer.beginElement("contour", attrs=attrs)
for point in obj["points"]:
writer.simpleElement("point", attrs=point)
writer.endElement("contour")
elif t == "component":
writer.simpleElement("component", attrs=obj)
writer.endElement("outline")
def _normalizeGlifContourFormat2(element):
"""
- Don't write unknown subelements.
"""
# INVALID DATA POSSIBILITY: unknown child element
# INVALID DATA POSSIBILITY: unknown point type
points = []
for subElement in element:
tag = subElement.tag
if tag != "point":
continue
attrs = _normalizeGlifPointAttributesFormat2(subElement)
if not attrs:
return
points.append(attrs)
if not points:
return
contour = dict(type="contour", points=points)
identifier = element.attrib.get("identifier")
if identifier is not None:
contour["identifier"] = identifier
return contour
def _normalizeGlifPointAttributesFormat2(element):
"""
- Follow same rules as Format 1, but allow an identifier attribute.
"""
attrs = _normalizeGlifPointAttributesFormat1(element)
identifier = element.attrib.get("identifier")
if identifier is not None:
attrs["identifier"] = identifier
return attrs