xref: /aosp_15_r20/external/fonttools/Lib/fontTools/ufoLib/__init__.py (revision e1fe3e4ad2793916b15cccdc4a7da52a7e1dd0e9)
1import os
2from copy import deepcopy
3from os import fsdecode
4import logging
5import zipfile
6import enum
7from collections import OrderedDict
8import fs
9import fs.base
10import fs.subfs
11import fs.errors
12import fs.copy
13import fs.osfs
14import fs.zipfs
15import fs.tempfs
16import fs.tools
17from fontTools.misc import plistlib
18from fontTools.ufoLib.validators import *
19from fontTools.ufoLib.filenames import userNameToFileName
20from fontTools.ufoLib.converters import convertUFO1OrUFO2KerningToUFO3Kerning
21from fontTools.ufoLib.errors import UFOLibError
22from fontTools.ufoLib.utils import numberTypes, _VersionTupleEnumMixin
23
24"""
25A library for importing .ufo files and their descendants.
26Refer to http://unifiedfontobject.com for the UFO specification.
27
28The UFOReader and UFOWriter classes support versions 1, 2 and 3
29of the specification.
30
31Sets that list the font info attribute names for the fontinfo.plist
32formats are available for external use. These are:
33	fontInfoAttributesVersion1
34	fontInfoAttributesVersion2
35	fontInfoAttributesVersion3
36
37A set listing the fontinfo.plist attributes that were deprecated
38in version 2 is available for external use:
39	deprecatedFontInfoAttributesVersion2
40
41Functions that do basic validation on values for fontinfo.plist
42are available for external use. These are
43	validateFontInfoVersion2ValueForAttribute
44	validateFontInfoVersion3ValueForAttribute
45
46Value conversion functions are available for converting
47fontinfo.plist values between the possible format versions.
48	convertFontInfoValueForAttributeFromVersion1ToVersion2
49	convertFontInfoValueForAttributeFromVersion2ToVersion1
50	convertFontInfoValueForAttributeFromVersion2ToVersion3
51	convertFontInfoValueForAttributeFromVersion3ToVersion2
52"""
53
54__all__ = [
55    "makeUFOPath",
56    "UFOLibError",
57    "UFOReader",
58    "UFOWriter",
59    "UFOReaderWriter",
60    "UFOFileStructure",
61    "fontInfoAttributesVersion1",
62    "fontInfoAttributesVersion2",
63    "fontInfoAttributesVersion3",
64    "deprecatedFontInfoAttributesVersion2",
65    "validateFontInfoVersion2ValueForAttribute",
66    "validateFontInfoVersion3ValueForAttribute",
67    "convertFontInfoValueForAttributeFromVersion1ToVersion2",
68    "convertFontInfoValueForAttributeFromVersion2ToVersion1",
69]
70
71__version__ = "3.0.0"
72
73
74logger = logging.getLogger(__name__)
75
76
77# ---------
78# Constants
79# ---------
80
81DEFAULT_GLYPHS_DIRNAME = "glyphs"
82DATA_DIRNAME = "data"
83IMAGES_DIRNAME = "images"
84METAINFO_FILENAME = "metainfo.plist"
85FONTINFO_FILENAME = "fontinfo.plist"
86LIB_FILENAME = "lib.plist"
87GROUPS_FILENAME = "groups.plist"
88KERNING_FILENAME = "kerning.plist"
89FEATURES_FILENAME = "features.fea"
90LAYERCONTENTS_FILENAME = "layercontents.plist"
91LAYERINFO_FILENAME = "layerinfo.plist"
92
93DEFAULT_LAYER_NAME = "public.default"
94
95
96class UFOFormatVersion(tuple, _VersionTupleEnumMixin, enum.Enum):
97    FORMAT_1_0 = (1, 0)
98    FORMAT_2_0 = (2, 0)
99    FORMAT_3_0 = (3, 0)
100
101
102# python 3.11 doesn't like when a mixin overrides a dunder method like __str__
103# for some reasons it keep using Enum.__str__, see
104# https://github.com/fonttools/fonttools/pull/2655
105UFOFormatVersion.__str__ = _VersionTupleEnumMixin.__str__
106
107
108class UFOFileStructure(enum.Enum):
109    ZIP = "zip"
110    PACKAGE = "package"
111
112
113# --------------
114# Shared Methods
115# --------------
116
117
118class _UFOBaseIO:
119    def getFileModificationTime(self, path):
120        """
121        Returns the modification time for the file at the given path, as a
122        floating point number giving the number of seconds since the epoch.
123        The path must be relative to the UFO path.
124        Returns None if the file does not exist.
125        """
126        try:
127            dt = self.fs.getinfo(fsdecode(path), namespaces=["details"]).modified
128        except (fs.errors.MissingInfoNamespace, fs.errors.ResourceNotFound):
129            return None
130        else:
131            return dt.timestamp()
132
133    def _getPlist(self, fileName, default=None):
134        """
135        Read a property list relative to the UFO filesystem's root.
136        Raises UFOLibError if the file is missing and default is None,
137        otherwise default is returned.
138
139        The errors that could be raised during the reading of a plist are
140        unpredictable and/or too large to list, so, a blind try: except:
141        is done. If an exception occurs, a UFOLibError will be raised.
142        """
143        try:
144            with self.fs.open(fileName, "rb") as f:
145                return plistlib.load(f)
146        except fs.errors.ResourceNotFound:
147            if default is None:
148                raise UFOLibError(
149                    "'%s' is missing on %s. This file is required" % (fileName, self.fs)
150                )
151            else:
152                return default
153        except Exception as e:
154            # TODO(anthrotype): try to narrow this down a little
155            raise UFOLibError(f"'{fileName}' could not be read on {self.fs}: {e}")
156
157    def _writePlist(self, fileName, obj):
158        """
159        Write a property list to a file relative to the UFO filesystem's root.
160
161        Do this sort of atomically, making it harder to corrupt existing files,
162        for example when plistlib encounters an error halfway during write.
163        This also checks to see if text matches the text that is already in the
164        file at path. If so, the file is not rewritten so that the modification
165        date is preserved.
166
167        The errors that could be raised during the writing of a plist are
168        unpredictable and/or too large to list, so, a blind try: except: is done.
169        If an exception occurs, a UFOLibError will be raised.
170        """
171        if self._havePreviousFile:
172            try:
173                data = plistlib.dumps(obj)
174            except Exception as e:
175                raise UFOLibError(
176                    "'%s' could not be written on %s because "
177                    "the data is not properly formatted: %s" % (fileName, self.fs, e)
178                )
179            if self.fs.exists(fileName) and data == self.fs.readbytes(fileName):
180                return
181            self.fs.writebytes(fileName, data)
182        else:
183            with self.fs.openbin(fileName, mode="w") as fp:
184                try:
185                    plistlib.dump(obj, fp)
186                except Exception as e:
187                    raise UFOLibError(
188                        "'%s' could not be written on %s because "
189                        "the data is not properly formatted: %s"
190                        % (fileName, self.fs, e)
191                    )
192
193
194# ----------
195# UFO Reader
196# ----------
197
198
199class UFOReader(_UFOBaseIO):
200    """
201    Read the various components of the .ufo.
202
203    By default read data is validated. Set ``validate`` to
204    ``False`` to not validate the data.
205    """
206
207    def __init__(self, path, validate=True):
208        if hasattr(path, "__fspath__"):  # support os.PathLike objects
209            path = path.__fspath__()
210
211        if isinstance(path, str):
212            structure = _sniffFileStructure(path)
213            try:
214                if structure is UFOFileStructure.ZIP:
215                    parentFS = fs.zipfs.ZipFS(path, write=False, encoding="utf-8")
216                else:
217                    parentFS = fs.osfs.OSFS(path)
218            except fs.errors.CreateFailed as e:
219                raise UFOLibError(f"unable to open '{path}': {e}")
220
221            if structure is UFOFileStructure.ZIP:
222                # .ufoz zip files must contain a single root directory, with arbitrary
223                # name, containing all the UFO files
224                rootDirs = [
225                    p.name
226                    for p in parentFS.scandir("/")
227                    # exclude macOS metadata contained in zip file
228                    if p.is_dir and p.name != "__MACOSX"
229                ]
230                if len(rootDirs) == 1:
231                    # 'ClosingSubFS' ensures that the parent zip file is closed when
232                    # its root subdirectory is closed
233                    self.fs = parentFS.opendir(
234                        rootDirs[0], factory=fs.subfs.ClosingSubFS
235                    )
236                else:
237                    raise UFOLibError(
238                        "Expected exactly 1 root directory, found %d" % len(rootDirs)
239                    )
240            else:
241                # normal UFO 'packages' are just a single folder
242                self.fs = parentFS
243            # when passed a path string, we make sure we close the newly opened fs
244            # upon calling UFOReader.close method or context manager's __exit__
245            self._shouldClose = True
246            self._fileStructure = structure
247        elif isinstance(path, fs.base.FS):
248            filesystem = path
249            try:
250                filesystem.check()
251            except fs.errors.FilesystemClosed:
252                raise UFOLibError("the filesystem '%s' is closed" % path)
253            else:
254                self.fs = filesystem
255            try:
256                path = filesystem.getsyspath("/")
257            except fs.errors.NoSysPath:
258                # network or in-memory FS may not map to the local one
259                path = str(filesystem)
260            # when user passed an already initialized fs instance, it is her
261            # responsibility to close it, thus UFOReader.close/__exit__ are no-op
262            self._shouldClose = False
263            # default to a 'package' structure
264            self._fileStructure = UFOFileStructure.PACKAGE
265        else:
266            raise TypeError(
267                "Expected a path string or fs.base.FS object, found '%s'"
268                % type(path).__name__
269            )
270        self._path = fsdecode(path)
271        self._validate = validate
272        self._upConvertedKerningData = None
273
274        try:
275            self.readMetaInfo(validate=validate)
276        except UFOLibError:
277            self.close()
278            raise
279
280    # properties
281
282    def _get_path(self):
283        import warnings
284
285        warnings.warn(
286            "The 'path' attribute is deprecated; use the 'fs' attribute instead",
287            DeprecationWarning,
288            stacklevel=2,
289        )
290        return self._path
291
292    path = property(_get_path, doc="The path of the UFO (DEPRECATED).")
293
294    def _get_formatVersion(self):
295        import warnings
296
297        warnings.warn(
298            "The 'formatVersion' attribute is deprecated; use the 'formatVersionTuple'",
299            DeprecationWarning,
300            stacklevel=2,
301        )
302        return self._formatVersion.major
303
304    formatVersion = property(
305        _get_formatVersion,
306        doc="The (major) format version of the UFO. DEPRECATED: Use formatVersionTuple",
307    )
308
309    @property
310    def formatVersionTuple(self):
311        """The (major, minor) format version of the UFO.
312        This is determined by reading metainfo.plist during __init__.
313        """
314        return self._formatVersion
315
316    def _get_fileStructure(self):
317        return self._fileStructure
318
319    fileStructure = property(
320        _get_fileStructure,
321        doc=(
322            "The file structure of the UFO: "
323            "either UFOFileStructure.ZIP or UFOFileStructure.PACKAGE"
324        ),
325    )
326
327    # up conversion
328
329    def _upConvertKerning(self, validate):
330        """
331        Up convert kerning and groups in UFO 1 and 2.
332        The data will be held internally until each bit of data
333        has been retrieved. The conversion of both must be done
334        at once, so the raw data is cached and an error is raised
335        if one bit of data becomes obsolete before it is called.
336
337        ``validate`` will validate the data.
338        """
339        if self._upConvertedKerningData:
340            testKerning = self._readKerning()
341            if testKerning != self._upConvertedKerningData["originalKerning"]:
342                raise UFOLibError(
343                    "The data in kerning.plist has been modified since it was converted to UFO 3 format."
344                )
345            testGroups = self._readGroups()
346            if testGroups != self._upConvertedKerningData["originalGroups"]:
347                raise UFOLibError(
348                    "The data in groups.plist has been modified since it was converted to UFO 3 format."
349                )
350        else:
351            groups = self._readGroups()
352            if validate:
353                invalidFormatMessage = "groups.plist is not properly formatted."
354                if not isinstance(groups, dict):
355                    raise UFOLibError(invalidFormatMessage)
356                for groupName, glyphList in groups.items():
357                    if not isinstance(groupName, str):
358                        raise UFOLibError(invalidFormatMessage)
359                    elif not isinstance(glyphList, list):
360                        raise UFOLibError(invalidFormatMessage)
361                    for glyphName in glyphList:
362                        if not isinstance(glyphName, str):
363                            raise UFOLibError(invalidFormatMessage)
364            self._upConvertedKerningData = dict(
365                kerning={},
366                originalKerning=self._readKerning(),
367                groups={},
368                originalGroups=groups,
369            )
370            # convert kerning and groups
371            kerning, groups, conversionMaps = convertUFO1OrUFO2KerningToUFO3Kerning(
372                self._upConvertedKerningData["originalKerning"],
373                deepcopy(self._upConvertedKerningData["originalGroups"]),
374                self.getGlyphSet(),
375            )
376            # store
377            self._upConvertedKerningData["kerning"] = kerning
378            self._upConvertedKerningData["groups"] = groups
379            self._upConvertedKerningData["groupRenameMaps"] = conversionMaps
380
381    # support methods
382
383    def readBytesFromPath(self, path):
384        """
385        Returns the bytes in the file at the given path.
386        The path must be relative to the UFO's filesystem root.
387        Returns None if the file does not exist.
388        """
389        try:
390            return self.fs.readbytes(fsdecode(path))
391        except fs.errors.ResourceNotFound:
392            return None
393
394    def getReadFileForPath(self, path, encoding=None):
395        """
396        Returns a file (or file-like) object for the file at the given path.
397        The path must be relative to the UFO path.
398        Returns None if the file does not exist.
399        By default the file is opened in binary mode (reads bytes).
400        If encoding is passed, the file is opened in text mode (reads str).
401
402        Note: The caller is responsible for closing the open file.
403        """
404        path = fsdecode(path)
405        try:
406            if encoding is None:
407                return self.fs.openbin(path)
408            else:
409                return self.fs.open(path, mode="r", encoding=encoding)
410        except fs.errors.ResourceNotFound:
411            return None
412
413    # metainfo.plist
414
415    def _readMetaInfo(self, validate=None):
416        """
417        Read metainfo.plist and return raw data. Only used for internal operations.
418
419        ``validate`` will validate the read data, by default it is set
420        to the class's validate value, can be overridden.
421        """
422        if validate is None:
423            validate = self._validate
424        data = self._getPlist(METAINFO_FILENAME)
425        if validate and not isinstance(data, dict):
426            raise UFOLibError("metainfo.plist is not properly formatted.")
427        try:
428            formatVersionMajor = data["formatVersion"]
429        except KeyError:
430            raise UFOLibError(
431                f"Missing required formatVersion in '{METAINFO_FILENAME}' on {self.fs}"
432            )
433        formatVersionMinor = data.setdefault("formatVersionMinor", 0)
434
435        try:
436            formatVersion = UFOFormatVersion((formatVersionMajor, formatVersionMinor))
437        except ValueError as e:
438            unsupportedMsg = (
439                f"Unsupported UFO format ({formatVersionMajor}.{formatVersionMinor}) "
440                f"in '{METAINFO_FILENAME}' on {self.fs}"
441            )
442            if validate:
443                from fontTools.ufoLib.errors import UnsupportedUFOFormat
444
445                raise UnsupportedUFOFormat(unsupportedMsg) from e
446
447            formatVersion = UFOFormatVersion.default()
448            logger.warning(
449                "%s. Assuming the latest supported version (%s). "
450                "Some data may be skipped or parsed incorrectly",
451                unsupportedMsg,
452                formatVersion,
453            )
454        data["formatVersionTuple"] = formatVersion
455        return data
456
457    def readMetaInfo(self, validate=None):
458        """
459        Read metainfo.plist and set formatVersion. Only used for internal operations.
460
461        ``validate`` will validate the read data, by default it is set
462        to the class's validate value, can be overridden.
463        """
464        data = self._readMetaInfo(validate=validate)
465        self._formatVersion = data["formatVersionTuple"]
466
467    # groups.plist
468
469    def _readGroups(self):
470        groups = self._getPlist(GROUPS_FILENAME, {})
471        # remove any duplicate glyphs in a kerning group
472        for groupName, glyphList in groups.items():
473            if groupName.startswith(("public.kern1.", "public.kern2.")):
474                groups[groupName] = list(OrderedDict.fromkeys(glyphList))
475        return groups
476
477    def readGroups(self, validate=None):
478        """
479        Read groups.plist. Returns a dict.
480        ``validate`` will validate the read data, by default it is set to the
481        class's validate value, can be overridden.
482        """
483        if validate is None:
484            validate = self._validate
485        # handle up conversion
486        if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
487            self._upConvertKerning(validate)
488            groups = self._upConvertedKerningData["groups"]
489        # normal
490        else:
491            groups = self._readGroups()
492        if validate:
493            valid, message = groupsValidator(groups)
494            if not valid:
495                raise UFOLibError(message)
496        return groups
497
498    def getKerningGroupConversionRenameMaps(self, validate=None):
499        """
500        Get maps defining the renaming that was done during any
501        needed kerning group conversion. This method returns a
502        dictionary of this form::
503
504                {
505                        "side1" : {"old group name" : "new group name"},
506                        "side2" : {"old group name" : "new group name"}
507                }
508
509        When no conversion has been performed, the side1 and side2
510        dictionaries will be empty.
511
512        ``validate`` will validate the groups, by default it is set to the
513        class's validate value, can be overridden.
514        """
515        if validate is None:
516            validate = self._validate
517        if self._formatVersion >= UFOFormatVersion.FORMAT_3_0:
518            return dict(side1={}, side2={})
519        # use the public group reader to force the load and
520        # conversion of the data if it hasn't happened yet.
521        self.readGroups(validate=validate)
522        return self._upConvertedKerningData["groupRenameMaps"]
523
524    # fontinfo.plist
525
526    def _readInfo(self, validate):
527        data = self._getPlist(FONTINFO_FILENAME, {})
528        if validate and not isinstance(data, dict):
529            raise UFOLibError("fontinfo.plist is not properly formatted.")
530        return data
531
532    def readInfo(self, info, validate=None):
533        """
534        Read fontinfo.plist. It requires an object that allows
535        setting attributes with names that follow the fontinfo.plist
536        version 3 specification. This will write the attributes
537        defined in the file into the object.
538
539        ``validate`` will validate the read data, by default it is set to the
540        class's validate value, can be overridden.
541        """
542        if validate is None:
543            validate = self._validate
544        infoDict = self._readInfo(validate)
545        infoDataToSet = {}
546        # version 1
547        if self._formatVersion == UFOFormatVersion.FORMAT_1_0:
548            for attr in fontInfoAttributesVersion1:
549                value = infoDict.get(attr)
550                if value is not None:
551                    infoDataToSet[attr] = value
552            infoDataToSet = _convertFontInfoDataVersion1ToVersion2(infoDataToSet)
553            infoDataToSet = _convertFontInfoDataVersion2ToVersion3(infoDataToSet)
554        # version 2
555        elif self._formatVersion == UFOFormatVersion.FORMAT_2_0:
556            for attr, dataValidationDict in list(
557                fontInfoAttributesVersion2ValueData.items()
558            ):
559                value = infoDict.get(attr)
560                if value is None:
561                    continue
562                infoDataToSet[attr] = value
563            infoDataToSet = _convertFontInfoDataVersion2ToVersion3(infoDataToSet)
564        # version 3.x
565        elif self._formatVersion.major == UFOFormatVersion.FORMAT_3_0.major:
566            for attr, dataValidationDict in list(
567                fontInfoAttributesVersion3ValueData.items()
568            ):
569                value = infoDict.get(attr)
570                if value is None:
571                    continue
572                infoDataToSet[attr] = value
573        # unsupported version
574        else:
575            raise NotImplementedError(self._formatVersion)
576        # validate data
577        if validate:
578            infoDataToSet = validateInfoVersion3Data(infoDataToSet)
579        # populate the object
580        for attr, value in list(infoDataToSet.items()):
581            try:
582                setattr(info, attr, value)
583            except AttributeError:
584                raise UFOLibError(
585                    "The supplied info object does not support setting a necessary attribute (%s)."
586                    % attr
587                )
588
589    # kerning.plist
590
591    def _readKerning(self):
592        data = self._getPlist(KERNING_FILENAME, {})
593        return data
594
595    def readKerning(self, validate=None):
596        """
597        Read kerning.plist. Returns a dict.
598
599        ``validate`` will validate the kerning data, by default it is set to the
600        class's validate value, can be overridden.
601        """
602        if validate is None:
603            validate = self._validate
604        # handle up conversion
605        if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
606            self._upConvertKerning(validate)
607            kerningNested = self._upConvertedKerningData["kerning"]
608        # normal
609        else:
610            kerningNested = self._readKerning()
611        if validate:
612            valid, message = kerningValidator(kerningNested)
613            if not valid:
614                raise UFOLibError(message)
615        # flatten
616        kerning = {}
617        for left in kerningNested:
618            for right in kerningNested[left]:
619                value = kerningNested[left][right]
620                kerning[left, right] = value
621        return kerning
622
623    # lib.plist
624
625    def readLib(self, validate=None):
626        """
627        Read lib.plist. Returns a dict.
628
629        ``validate`` will validate the data, by default it is set to the
630        class's validate value, can be overridden.
631        """
632        if validate is None:
633            validate = self._validate
634        data = self._getPlist(LIB_FILENAME, {})
635        if validate:
636            valid, message = fontLibValidator(data)
637            if not valid:
638                raise UFOLibError(message)
639        return data
640
641    # features.fea
642
643    def readFeatures(self):
644        """
645        Read features.fea. Return a string.
646        The returned string is empty if the file is missing.
647        """
648        try:
649            with self.fs.open(FEATURES_FILENAME, "r", encoding="utf-8") as f:
650                return f.read()
651        except fs.errors.ResourceNotFound:
652            return ""
653
654    # glyph sets & layers
655
656    def _readLayerContents(self, validate):
657        """
658        Rebuild the layer contents list by checking what glyphsets
659        are available on disk.
660
661        ``validate`` will validate the layer contents.
662        """
663        if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
664            return [(DEFAULT_LAYER_NAME, DEFAULT_GLYPHS_DIRNAME)]
665        contents = self._getPlist(LAYERCONTENTS_FILENAME)
666        if validate:
667            valid, error = layerContentsValidator(contents, self.fs)
668            if not valid:
669                raise UFOLibError(error)
670        return contents
671
672    def getLayerNames(self, validate=None):
673        """
674        Get the ordered layer names from layercontents.plist.
675
676        ``validate`` will validate the data, by default it is set to the
677        class's validate value, can be overridden.
678        """
679        if validate is None:
680            validate = self._validate
681        layerContents = self._readLayerContents(validate)
682        layerNames = [layerName for layerName, directoryName in layerContents]
683        return layerNames
684
685    def getDefaultLayerName(self, validate=None):
686        """
687        Get the default layer name from layercontents.plist.
688
689        ``validate`` will validate the data, by default it is set to the
690        class's validate value, can be overridden.
691        """
692        if validate is None:
693            validate = self._validate
694        layerContents = self._readLayerContents(validate)
695        for layerName, layerDirectory in layerContents:
696            if layerDirectory == DEFAULT_GLYPHS_DIRNAME:
697                return layerName
698        # this will already have been raised during __init__
699        raise UFOLibError("The default layer is not defined in layercontents.plist.")
700
701    def getGlyphSet(self, layerName=None, validateRead=None, validateWrite=None):
702        """
703        Return the GlyphSet associated with the
704        glyphs directory mapped to layerName
705        in the UFO. If layerName is not provided,
706        the name retrieved with getDefaultLayerName
707        will be used.
708
709        ``validateRead`` will validate the read data, by default it is set to the
710        class's validate value, can be overridden.
711        ``validateWrite`` will validate the written data, by default it is set to the
712        class's validate value, can be overridden.
713        """
714        from fontTools.ufoLib.glifLib import GlyphSet
715
716        if validateRead is None:
717            validateRead = self._validate
718        if validateWrite is None:
719            validateWrite = self._validate
720        if layerName is None:
721            layerName = self.getDefaultLayerName(validate=validateRead)
722        directory = None
723        layerContents = self._readLayerContents(validateRead)
724        for storedLayerName, storedLayerDirectory in layerContents:
725            if layerName == storedLayerName:
726                directory = storedLayerDirectory
727                break
728        if directory is None:
729            raise UFOLibError('No glyphs directory is mapped to "%s".' % layerName)
730        try:
731            glyphSubFS = self.fs.opendir(directory)
732        except fs.errors.ResourceNotFound:
733            raise UFOLibError(f"No '{directory}' directory for layer '{layerName}'")
734        return GlyphSet(
735            glyphSubFS,
736            ufoFormatVersion=self._formatVersion,
737            validateRead=validateRead,
738            validateWrite=validateWrite,
739            expectContentsFile=True,
740        )
741
742    def getCharacterMapping(self, layerName=None, validate=None):
743        """
744        Return a dictionary that maps unicode values (ints) to
745        lists of glyph names.
746        """
747        if validate is None:
748            validate = self._validate
749        glyphSet = self.getGlyphSet(
750            layerName, validateRead=validate, validateWrite=True
751        )
752        allUnicodes = glyphSet.getUnicodes()
753        cmap = {}
754        for glyphName, unicodes in allUnicodes.items():
755            for code in unicodes:
756                if code in cmap:
757                    cmap[code].append(glyphName)
758                else:
759                    cmap[code] = [glyphName]
760        return cmap
761
762    # /data
763
764    def getDataDirectoryListing(self):
765        """
766        Returns a list of all files in the data directory.
767        The returned paths will be relative to the UFO.
768        This will not list directory names, only file names.
769        Thus, empty directories will be skipped.
770        """
771        try:
772            self._dataFS = self.fs.opendir(DATA_DIRNAME)
773        except fs.errors.ResourceNotFound:
774            return []
775        except fs.errors.DirectoryExpected:
776            raise UFOLibError('The UFO contains a "data" file instead of a directory.')
777        try:
778            # fs Walker.files method returns "absolute" paths (in terms of the
779            # root of the 'data' SubFS), so we strip the leading '/' to make
780            # them relative
781            return [p.lstrip("/") for p in self._dataFS.walk.files()]
782        except fs.errors.ResourceError:
783            return []
784
785    def getImageDirectoryListing(self, validate=None):
786        """
787        Returns a list of all image file names in
788        the images directory. Each of the images will
789        have been verified to have the PNG signature.
790
791        ``validate`` will validate the data, by default it is set to the
792        class's validate value, can be overridden.
793        """
794        if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
795            return []
796        if validate is None:
797            validate = self._validate
798        try:
799            self._imagesFS = imagesFS = self.fs.opendir(IMAGES_DIRNAME)
800        except fs.errors.ResourceNotFound:
801            return []
802        except fs.errors.DirectoryExpected:
803            raise UFOLibError(
804                'The UFO contains an "images" file instead of a directory.'
805            )
806        result = []
807        for path in imagesFS.scandir("/"):
808            if path.is_dir:
809                # silently skip this as version control
810                # systems often have hidden directories
811                continue
812            if validate:
813                with imagesFS.openbin(path.name) as fp:
814                    valid, error = pngValidator(fileObj=fp)
815                if valid:
816                    result.append(path.name)
817            else:
818                result.append(path.name)
819        return result
820
821    def readData(self, fileName):
822        """
823        Return bytes for the file named 'fileName' inside the 'data/' directory.
824        """
825        fileName = fsdecode(fileName)
826        try:
827            try:
828                dataFS = self._dataFS
829            except AttributeError:
830                # in case readData is called before getDataDirectoryListing
831                dataFS = self.fs.opendir(DATA_DIRNAME)
832            data = dataFS.readbytes(fileName)
833        except fs.errors.ResourceNotFound:
834            raise UFOLibError(f"No data file named '{fileName}' on {self.fs}")
835        return data
836
837    def readImage(self, fileName, validate=None):
838        """
839        Return image data for the file named fileName.
840
841        ``validate`` will validate the data, by default it is set to the
842        class's validate value, can be overridden.
843        """
844        if validate is None:
845            validate = self._validate
846        if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
847            raise UFOLibError(
848                f"Reading images is not allowed in UFO {self._formatVersion.major}."
849            )
850        fileName = fsdecode(fileName)
851        try:
852            try:
853                imagesFS = self._imagesFS
854            except AttributeError:
855                # in case readImage is called before getImageDirectoryListing
856                imagesFS = self.fs.opendir(IMAGES_DIRNAME)
857            data = imagesFS.readbytes(fileName)
858        except fs.errors.ResourceNotFound:
859            raise UFOLibError(f"No image file named '{fileName}' on {self.fs}")
860        if validate:
861            valid, error = pngValidator(data=data)
862            if not valid:
863                raise UFOLibError(error)
864        return data
865
866    def close(self):
867        if self._shouldClose:
868            self.fs.close()
869
870    def __enter__(self):
871        return self
872
873    def __exit__(self, exc_type, exc_value, exc_tb):
874        self.close()
875
876
877# ----------
878# UFO Writer
879# ----------
880
881
882class UFOWriter(UFOReader):
883    """
884    Write the various components of the .ufo.
885
886    By default, the written data will be validated before writing. Set ``validate`` to
887    ``False`` if you do not want to validate the data. Validation can also be overriden
888    on a per method level if desired.
889
890    The ``formatVersion`` argument allows to specify the UFO format version as a tuple
891    of integers (major, minor), or as a single integer for the major digit only (minor
892    is implied as 0). By default the latest formatVersion will be used; currently it's
893    3.0, which is equivalent to formatVersion=(3, 0).
894
895    An UnsupportedUFOFormat exception is raised if the requested UFO formatVersion is
896    not supported.
897    """
898
899    def __init__(
900        self,
901        path,
902        formatVersion=None,
903        fileCreator="com.github.fonttools.ufoLib",
904        structure=None,
905        validate=True,
906    ):
907        try:
908            formatVersion = UFOFormatVersion(formatVersion)
909        except ValueError as e:
910            from fontTools.ufoLib.errors import UnsupportedUFOFormat
911
912            raise UnsupportedUFOFormat(
913                f"Unsupported UFO format: {formatVersion!r}"
914            ) from e
915
916        if hasattr(path, "__fspath__"):  # support os.PathLike objects
917            path = path.__fspath__()
918
919        if isinstance(path, str):
920            # normalize path by removing trailing or double slashes
921            path = os.path.normpath(path)
922            havePreviousFile = os.path.exists(path)
923            if havePreviousFile:
924                # ensure we use the same structure as the destination
925                existingStructure = _sniffFileStructure(path)
926                if structure is not None:
927                    try:
928                        structure = UFOFileStructure(structure)
929                    except ValueError:
930                        raise UFOLibError(
931                            "Invalid or unsupported structure: '%s'" % structure
932                        )
933                    if structure is not existingStructure:
934                        raise UFOLibError(
935                            "A UFO with a different structure (%s) already exists "
936                            "at the given path: '%s'" % (existingStructure, path)
937                        )
938                else:
939                    structure = existingStructure
940            else:
941                # if not exists, default to 'package' structure
942                if structure is None:
943                    structure = UFOFileStructure.PACKAGE
944                dirName = os.path.dirname(path)
945                if dirName and not os.path.isdir(dirName):
946                    raise UFOLibError(
947                        "Cannot write to '%s': directory does not exist" % path
948                    )
949            if structure is UFOFileStructure.ZIP:
950                if havePreviousFile:
951                    # we can't write a zip in-place, so we have to copy its
952                    # contents to a temporary location and work from there, then
953                    # upon closing UFOWriter we create the final zip file
954                    parentFS = fs.tempfs.TempFS()
955                    with fs.zipfs.ZipFS(path, encoding="utf-8") as origFS:
956                        fs.copy.copy_fs(origFS, parentFS)
957                    # if output path is an existing zip, we require that it contains
958                    # one, and only one, root directory (with arbitrary name), in turn
959                    # containing all the existing UFO contents
960                    rootDirs = [
961                        p.name
962                        for p in parentFS.scandir("/")
963                        # exclude macOS metadata contained in zip file
964                        if p.is_dir and p.name != "__MACOSX"
965                    ]
966                    if len(rootDirs) != 1:
967                        raise UFOLibError(
968                            "Expected exactly 1 root directory, found %d"
969                            % len(rootDirs)
970                        )
971                    else:
972                        # 'ClosingSubFS' ensures that the parent filesystem is closed
973                        # when its root subdirectory is closed
974                        self.fs = parentFS.opendir(
975                            rootDirs[0], factory=fs.subfs.ClosingSubFS
976                        )
977                else:
978                    # if the output zip file didn't exist, we create the root folder;
979                    # we name it the same as input 'path', but with '.ufo' extension
980                    rootDir = os.path.splitext(os.path.basename(path))[0] + ".ufo"
981                    parentFS = fs.zipfs.ZipFS(path, write=True, encoding="utf-8")
982                    parentFS.makedir(rootDir)
983                    self.fs = parentFS.opendir(rootDir, factory=fs.subfs.ClosingSubFS)
984            else:
985                self.fs = fs.osfs.OSFS(path, create=True)
986            self._fileStructure = structure
987            self._havePreviousFile = havePreviousFile
988            self._shouldClose = True
989        elif isinstance(path, fs.base.FS):
990            filesystem = path
991            try:
992                filesystem.check()
993            except fs.errors.FilesystemClosed:
994                raise UFOLibError("the filesystem '%s' is closed" % path)
995            else:
996                self.fs = filesystem
997            try:
998                path = filesystem.getsyspath("/")
999            except fs.errors.NoSysPath:
1000                # network or in-memory FS may not map to the local one
1001                path = str(filesystem)
1002            # if passed an FS object, always use 'package' structure
1003            if structure and structure is not UFOFileStructure.PACKAGE:
1004                import warnings
1005
1006                warnings.warn(
1007                    "The 'structure' argument is not used when input is an FS object",
1008                    UserWarning,
1009                    stacklevel=2,
1010                )
1011            self._fileStructure = UFOFileStructure.PACKAGE
1012            # if FS contains a "metainfo.plist", we consider it non-empty
1013            self._havePreviousFile = filesystem.exists(METAINFO_FILENAME)
1014            # the user is responsible for closing the FS object
1015            self._shouldClose = False
1016        else:
1017            raise TypeError(
1018                "Expected a path string or fs object, found %s" % type(path).__name__
1019            )
1020
1021        # establish some basic stuff
1022        self._path = fsdecode(path)
1023        self._formatVersion = formatVersion
1024        self._fileCreator = fileCreator
1025        self._downConversionKerningData = None
1026        self._validate = validate
1027        # if the file already exists, get the format version.
1028        # this will be needed for up and down conversion.
1029        previousFormatVersion = None
1030        if self._havePreviousFile:
1031            metaInfo = self._readMetaInfo(validate=validate)
1032            previousFormatVersion = metaInfo["formatVersionTuple"]
1033            # catch down conversion
1034            if previousFormatVersion > formatVersion:
1035                from fontTools.ufoLib.errors import UnsupportedUFOFormat
1036
1037                raise UnsupportedUFOFormat(
1038                    "The UFO located at this path is a higher version "
1039                    f"({previousFormatVersion}) than the version ({formatVersion}) "
1040                    "that is trying to be written. This is not supported."
1041                )
1042        # handle the layer contents
1043        self.layerContents = {}
1044        if previousFormatVersion is not None and previousFormatVersion.major >= 3:
1045            # already exists
1046            self.layerContents = OrderedDict(self._readLayerContents(validate))
1047        else:
1048            # previous < 3
1049            # imply the layer contents
1050            if self.fs.exists(DEFAULT_GLYPHS_DIRNAME):
1051                self.layerContents = {DEFAULT_LAYER_NAME: DEFAULT_GLYPHS_DIRNAME}
1052        # write the new metainfo
1053        self._writeMetaInfo()
1054
1055    # properties
1056
1057    def _get_fileCreator(self):
1058        return self._fileCreator
1059
1060    fileCreator = property(
1061        _get_fileCreator,
1062        doc="The file creator of the UFO. This is set into metainfo.plist during __init__.",
1063    )
1064
1065    # support methods for file system interaction
1066
1067    def copyFromReader(self, reader, sourcePath, destPath):
1068        """
1069        Copy the sourcePath in the provided UFOReader to destPath
1070        in this writer. The paths must be relative. This works with
1071        both individual files and directories.
1072        """
1073        if not isinstance(reader, UFOReader):
1074            raise UFOLibError("The reader must be an instance of UFOReader.")
1075        sourcePath = fsdecode(sourcePath)
1076        destPath = fsdecode(destPath)
1077        if not reader.fs.exists(sourcePath):
1078            raise UFOLibError(
1079                'The reader does not have data located at "%s".' % sourcePath
1080            )
1081        if self.fs.exists(destPath):
1082            raise UFOLibError('A file named "%s" already exists.' % destPath)
1083        # create the destination directory if it doesn't exist
1084        self.fs.makedirs(fs.path.dirname(destPath), recreate=True)
1085        if reader.fs.isdir(sourcePath):
1086            fs.copy.copy_dir(reader.fs, sourcePath, self.fs, destPath)
1087        else:
1088            fs.copy.copy_file(reader.fs, sourcePath, self.fs, destPath)
1089
1090    def writeBytesToPath(self, path, data):
1091        """
1092        Write bytes to a path relative to the UFO filesystem's root.
1093        If writing to an existing UFO, check to see if data matches the data
1094        that is already in the file at path; if so, the file is not rewritten
1095        so that the modification date is preserved.
1096        If needed, the directory tree for the given path will be built.
1097        """
1098        path = fsdecode(path)
1099        if self._havePreviousFile:
1100            if self.fs.isfile(path) and data == self.fs.readbytes(path):
1101                return
1102        try:
1103            self.fs.writebytes(path, data)
1104        except fs.errors.FileExpected:
1105            raise UFOLibError("A directory exists at '%s'" % path)
1106        except fs.errors.ResourceNotFound:
1107            self.fs.makedirs(fs.path.dirname(path), recreate=True)
1108            self.fs.writebytes(path, data)
1109
1110    def getFileObjectForPath(self, path, mode="w", encoding=None):
1111        """
1112        Returns a file (or file-like) object for the
1113        file at the given path. The path must be relative
1114        to the UFO path. Returns None if the file does
1115        not exist and the mode is "r" or "rb.
1116        An encoding may be passed if the file is opened in text mode.
1117
1118        Note: The caller is responsible for closing the open file.
1119        """
1120        path = fsdecode(path)
1121        try:
1122            return self.fs.open(path, mode=mode, encoding=encoding)
1123        except fs.errors.ResourceNotFound as e:
1124            m = mode[0]
1125            if m == "r":
1126                # XXX I think we should just let it raise. The docstring,
1127                # however, says that this returns None if mode is 'r'
1128                return None
1129            elif m == "w" or m == "a" or m == "x":
1130                self.fs.makedirs(fs.path.dirname(path), recreate=True)
1131                return self.fs.open(path, mode=mode, encoding=encoding)
1132        except fs.errors.ResourceError as e:
1133            return UFOLibError(f"unable to open '{path}' on {self.fs}: {e}")
1134
1135    def removePath(self, path, force=False, removeEmptyParents=True):
1136        """
1137        Remove the file (or directory) at path. The path
1138        must be relative to the UFO.
1139        Raises UFOLibError if the path doesn't exist.
1140        If force=True, ignore non-existent paths.
1141        If the directory where 'path' is located becomes empty, it will
1142        be automatically removed, unless 'removeEmptyParents' is False.
1143        """
1144        path = fsdecode(path)
1145        try:
1146            self.fs.remove(path)
1147        except fs.errors.FileExpected:
1148            self.fs.removetree(path)
1149        except fs.errors.ResourceNotFound:
1150            if not force:
1151                raise UFOLibError(f"'{path}' does not exist on {self.fs}")
1152        if removeEmptyParents:
1153            parent = fs.path.dirname(path)
1154            if parent:
1155                fs.tools.remove_empty(self.fs, parent)
1156
1157    # alias kept for backward compatibility with old API
1158    removeFileForPath = removePath
1159
1160    # UFO mod time
1161
1162    def setModificationTime(self):
1163        """
1164        Set the UFO modification time to the current time.
1165        This is never called automatically. It is up to the
1166        caller to call this when finished working on the UFO.
1167        """
1168        path = self._path
1169        if path is not None and os.path.exists(path):
1170            try:
1171                # this may fail on some filesystems (e.g. SMB servers)
1172                os.utime(path, None)
1173            except OSError as e:
1174                logger.warning("Failed to set modified time: %s", e)
1175
1176    # metainfo.plist
1177
1178    def _writeMetaInfo(self):
1179        metaInfo = dict(
1180            creator=self._fileCreator,
1181            formatVersion=self._formatVersion.major,
1182        )
1183        if self._formatVersion.minor != 0:
1184            metaInfo["formatVersionMinor"] = self._formatVersion.minor
1185        self._writePlist(METAINFO_FILENAME, metaInfo)
1186
1187    # groups.plist
1188
1189    def setKerningGroupConversionRenameMaps(self, maps):
1190        """
1191        Set maps defining the renaming that should be done
1192        when writing groups and kerning in UFO 1 and UFO 2.
1193        This will effectively undo the conversion done when
1194        UFOReader reads this data. The dictionary should have
1195        this form::
1196
1197                {
1198                        "side1" : {"group name to use when writing" : "group name in data"},
1199                        "side2" : {"group name to use when writing" : "group name in data"}
1200                }
1201
1202        This is the same form returned by UFOReader's
1203        getKerningGroupConversionRenameMaps method.
1204        """
1205        if self._formatVersion >= UFOFormatVersion.FORMAT_3_0:
1206            return  # XXX raise an error here
1207        # flip the dictionaries
1208        remap = {}
1209        for side in ("side1", "side2"):
1210            for writeName, dataName in list(maps[side].items()):
1211                remap[dataName] = writeName
1212        self._downConversionKerningData = dict(groupRenameMap=remap)
1213
1214    def writeGroups(self, groups, validate=None):
1215        """
1216        Write groups.plist. This method requires a
1217        dict of glyph groups as an argument.
1218
1219        ``validate`` will validate the data, by default it is set to the
1220        class's validate value, can be overridden.
1221        """
1222        if validate is None:
1223            validate = self._validate
1224        # validate the data structure
1225        if validate:
1226            valid, message = groupsValidator(groups)
1227            if not valid:
1228                raise UFOLibError(message)
1229        # down convert
1230        if (
1231            self._formatVersion < UFOFormatVersion.FORMAT_3_0
1232            and self._downConversionKerningData is not None
1233        ):
1234            remap = self._downConversionKerningData["groupRenameMap"]
1235            remappedGroups = {}
1236            # there are some edge cases here that are ignored:
1237            # 1. if a group is being renamed to a name that
1238            #    already exists, the existing group is always
1239            #    overwritten. (this is why there are two loops
1240            #    below.) there doesn't seem to be a logical
1241            #    solution to groups mismatching and overwriting
1242            #    with the specifiecd group seems like a better
1243            #    solution than throwing an error.
1244            # 2. if side 1 and side 2 groups are being renamed
1245            #    to the same group name there is no check to
1246            #    ensure that the contents are identical. that
1247            #    is left up to the caller.
1248            for name, contents in list(groups.items()):
1249                if name in remap:
1250                    continue
1251                remappedGroups[name] = contents
1252            for name, contents in list(groups.items()):
1253                if name not in remap:
1254                    continue
1255                name = remap[name]
1256                remappedGroups[name] = contents
1257            groups = remappedGroups
1258        # pack and write
1259        groupsNew = {}
1260        for key, value in groups.items():
1261            groupsNew[key] = list(value)
1262        if groupsNew:
1263            self._writePlist(GROUPS_FILENAME, groupsNew)
1264        elif self._havePreviousFile:
1265            self.removePath(GROUPS_FILENAME, force=True, removeEmptyParents=False)
1266
1267    # fontinfo.plist
1268
1269    def writeInfo(self, info, validate=None):
1270        """
1271        Write info.plist. This method requires an object
1272        that supports getting attributes that follow the
1273        fontinfo.plist version 2 specification. Attributes
1274        will be taken from the given object and written
1275        into the file.
1276
1277        ``validate`` will validate the data, by default it is set to the
1278        class's validate value, can be overridden.
1279        """
1280        if validate is None:
1281            validate = self._validate
1282        # gather version 3 data
1283        infoData = {}
1284        for attr in list(fontInfoAttributesVersion3ValueData.keys()):
1285            if hasattr(info, attr):
1286                try:
1287                    value = getattr(info, attr)
1288                except AttributeError:
1289                    raise UFOLibError(
1290                        "The supplied info object does not support getting a necessary attribute (%s)."
1291                        % attr
1292                    )
1293                if value is None:
1294                    continue
1295                infoData[attr] = value
1296        # down convert data if necessary and validate
1297        if self._formatVersion == UFOFormatVersion.FORMAT_3_0:
1298            if validate:
1299                infoData = validateInfoVersion3Data(infoData)
1300        elif self._formatVersion == UFOFormatVersion.FORMAT_2_0:
1301            infoData = _convertFontInfoDataVersion3ToVersion2(infoData)
1302            if validate:
1303                infoData = validateInfoVersion2Data(infoData)
1304        elif self._formatVersion == UFOFormatVersion.FORMAT_1_0:
1305            infoData = _convertFontInfoDataVersion3ToVersion2(infoData)
1306            if validate:
1307                infoData = validateInfoVersion2Data(infoData)
1308            infoData = _convertFontInfoDataVersion2ToVersion1(infoData)
1309        # write file if there is anything to write
1310        if infoData:
1311            self._writePlist(FONTINFO_FILENAME, infoData)
1312
1313    # kerning.plist
1314
1315    def writeKerning(self, kerning, validate=None):
1316        """
1317        Write kerning.plist. This method requires a
1318        dict of kerning pairs as an argument.
1319
1320        This performs basic structural validation of the kerning,
1321        but it does not check for compliance with the spec in
1322        regards to conflicting pairs. The assumption is that the
1323        kerning data being passed is standards compliant.
1324
1325        ``validate`` will validate the data, by default it is set to the
1326        class's validate value, can be overridden.
1327        """
1328        if validate is None:
1329            validate = self._validate
1330        # validate the data structure
1331        if validate:
1332            invalidFormatMessage = "The kerning is not properly formatted."
1333            if not isDictEnough(kerning):
1334                raise UFOLibError(invalidFormatMessage)
1335            for pair, value in list(kerning.items()):
1336                if not isinstance(pair, (list, tuple)):
1337                    raise UFOLibError(invalidFormatMessage)
1338                if not len(pair) == 2:
1339                    raise UFOLibError(invalidFormatMessage)
1340                if not isinstance(pair[0], str):
1341                    raise UFOLibError(invalidFormatMessage)
1342                if not isinstance(pair[1], str):
1343                    raise UFOLibError(invalidFormatMessage)
1344                if not isinstance(value, numberTypes):
1345                    raise UFOLibError(invalidFormatMessage)
1346        # down convert
1347        if (
1348            self._formatVersion < UFOFormatVersion.FORMAT_3_0
1349            and self._downConversionKerningData is not None
1350        ):
1351            remap = self._downConversionKerningData["groupRenameMap"]
1352            remappedKerning = {}
1353            for (side1, side2), value in list(kerning.items()):
1354                side1 = remap.get(side1, side1)
1355                side2 = remap.get(side2, side2)
1356                remappedKerning[side1, side2] = value
1357            kerning = remappedKerning
1358        # pack and write
1359        kerningDict = {}
1360        for left, right in kerning.keys():
1361            value = kerning[left, right]
1362            if left not in kerningDict:
1363                kerningDict[left] = {}
1364            kerningDict[left][right] = value
1365        if kerningDict:
1366            self._writePlist(KERNING_FILENAME, kerningDict)
1367        elif self._havePreviousFile:
1368            self.removePath(KERNING_FILENAME, force=True, removeEmptyParents=False)
1369
1370    # lib.plist
1371
1372    def writeLib(self, libDict, validate=None):
1373        """
1374        Write lib.plist. This method requires a
1375        lib dict as an argument.
1376
1377        ``validate`` will validate the data, by default it is set to the
1378        class's validate value, can be overridden.
1379        """
1380        if validate is None:
1381            validate = self._validate
1382        if validate:
1383            valid, message = fontLibValidator(libDict)
1384            if not valid:
1385                raise UFOLibError(message)
1386        if libDict:
1387            self._writePlist(LIB_FILENAME, libDict)
1388        elif self._havePreviousFile:
1389            self.removePath(LIB_FILENAME, force=True, removeEmptyParents=False)
1390
1391    # features.fea
1392
1393    def writeFeatures(self, features, validate=None):
1394        """
1395        Write features.fea. This method requires a
1396        features string as an argument.
1397        """
1398        if validate is None:
1399            validate = self._validate
1400        if self._formatVersion == UFOFormatVersion.FORMAT_1_0:
1401            raise UFOLibError("features.fea is not allowed in UFO Format Version 1.")
1402        if validate:
1403            if not isinstance(features, str):
1404                raise UFOLibError("The features are not text.")
1405        if features:
1406            self.writeBytesToPath(FEATURES_FILENAME, features.encode("utf8"))
1407        elif self._havePreviousFile:
1408            self.removePath(FEATURES_FILENAME, force=True, removeEmptyParents=False)
1409
1410    # glyph sets & layers
1411
1412    def writeLayerContents(self, layerOrder=None, validate=None):
1413        """
1414        Write the layercontents.plist file. This method  *must* be called
1415        after all glyph sets have been written.
1416        """
1417        if validate is None:
1418            validate = self._validate
1419        if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
1420            return
1421        if layerOrder is not None:
1422            newOrder = []
1423            for layerName in layerOrder:
1424                if layerName is None:
1425                    layerName = DEFAULT_LAYER_NAME
1426                newOrder.append(layerName)
1427            layerOrder = newOrder
1428        else:
1429            layerOrder = list(self.layerContents.keys())
1430        if validate and set(layerOrder) != set(self.layerContents.keys()):
1431            raise UFOLibError(
1432                "The layer order content does not match the glyph sets that have been created."
1433            )
1434        layerContents = [
1435            (layerName, self.layerContents[layerName]) for layerName in layerOrder
1436        ]
1437        self._writePlist(LAYERCONTENTS_FILENAME, layerContents)
1438
1439    def _findDirectoryForLayerName(self, layerName):
1440        foundDirectory = None
1441        for existingLayerName, directoryName in list(self.layerContents.items()):
1442            if layerName is None and directoryName == DEFAULT_GLYPHS_DIRNAME:
1443                foundDirectory = directoryName
1444                break
1445            elif existingLayerName == layerName:
1446                foundDirectory = directoryName
1447                break
1448        if not foundDirectory:
1449            raise UFOLibError(
1450                "Could not locate a glyph set directory for the layer named %s."
1451                % layerName
1452            )
1453        return foundDirectory
1454
1455    def getGlyphSet(
1456        self,
1457        layerName=None,
1458        defaultLayer=True,
1459        glyphNameToFileNameFunc=None,
1460        validateRead=None,
1461        validateWrite=None,
1462        expectContentsFile=False,
1463    ):
1464        """
1465        Return the GlyphSet object associated with the
1466        appropriate glyph directory in the .ufo.
1467        If layerName is None, the default glyph set
1468        will be used. The defaultLayer flag indictes
1469        that the layer should be saved into the default
1470        glyphs directory.
1471
1472        ``validateRead`` will validate the read data, by default it is set to the
1473        class's validate value, can be overridden.
1474        ``validateWrte`` will validate the written data, by default it is set to the
1475        class's validate value, can be overridden.
1476        ``expectContentsFile`` will raise a GlifLibError if a contents.plist file is
1477        not found on the glyph set file system. This should be set to ``True`` if you
1478        are reading an existing UFO and ``False`` if you use ``getGlyphSet`` to create
1479        a fresh	glyph set.
1480        """
1481        if validateRead is None:
1482            validateRead = self._validate
1483        if validateWrite is None:
1484            validateWrite = self._validate
1485        # only default can be written in < 3
1486        if self._formatVersion < UFOFormatVersion.FORMAT_3_0 and (
1487            not defaultLayer or layerName is not None
1488        ):
1489            raise UFOLibError(
1490                f"Only the default layer can be writen in UFO {self._formatVersion.major}."
1491            )
1492        # locate a layer name when None has been given
1493        if layerName is None and defaultLayer:
1494            for existingLayerName, directory in self.layerContents.items():
1495                if directory == DEFAULT_GLYPHS_DIRNAME:
1496                    layerName = existingLayerName
1497            if layerName is None:
1498                layerName = DEFAULT_LAYER_NAME
1499        elif layerName is None and not defaultLayer:
1500            raise UFOLibError("A layer name must be provided for non-default layers.")
1501        # move along to format specific writing
1502        if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
1503            return self._getDefaultGlyphSet(
1504                validateRead,
1505                validateWrite,
1506                glyphNameToFileNameFunc=glyphNameToFileNameFunc,
1507                expectContentsFile=expectContentsFile,
1508            )
1509        elif self._formatVersion.major == UFOFormatVersion.FORMAT_3_0.major:
1510            return self._getGlyphSetFormatVersion3(
1511                validateRead,
1512                validateWrite,
1513                layerName=layerName,
1514                defaultLayer=defaultLayer,
1515                glyphNameToFileNameFunc=glyphNameToFileNameFunc,
1516                expectContentsFile=expectContentsFile,
1517            )
1518        else:
1519            raise NotImplementedError(self._formatVersion)
1520
1521    def _getDefaultGlyphSet(
1522        self,
1523        validateRead,
1524        validateWrite,
1525        glyphNameToFileNameFunc=None,
1526        expectContentsFile=False,
1527    ):
1528        from fontTools.ufoLib.glifLib import GlyphSet
1529
1530        glyphSubFS = self.fs.makedir(DEFAULT_GLYPHS_DIRNAME, recreate=True)
1531        return GlyphSet(
1532            glyphSubFS,
1533            glyphNameToFileNameFunc=glyphNameToFileNameFunc,
1534            ufoFormatVersion=self._formatVersion,
1535            validateRead=validateRead,
1536            validateWrite=validateWrite,
1537            expectContentsFile=expectContentsFile,
1538        )
1539
1540    def _getGlyphSetFormatVersion3(
1541        self,
1542        validateRead,
1543        validateWrite,
1544        layerName=None,
1545        defaultLayer=True,
1546        glyphNameToFileNameFunc=None,
1547        expectContentsFile=False,
1548    ):
1549        from fontTools.ufoLib.glifLib import GlyphSet
1550
1551        # if the default flag is on, make sure that the default in the file
1552        # matches the default being written. also make sure that this layer
1553        # name is not already linked to a non-default layer.
1554        if defaultLayer:
1555            for existingLayerName, directory in self.layerContents.items():
1556                if directory == DEFAULT_GLYPHS_DIRNAME:
1557                    if existingLayerName != layerName:
1558                        raise UFOLibError(
1559                            "Another layer ('%s') is already mapped to the default directory."
1560                            % existingLayerName
1561                        )
1562                elif existingLayerName == layerName:
1563                    raise UFOLibError(
1564                        "The layer name is already mapped to a non-default layer."
1565                    )
1566        # get an existing directory name
1567        if layerName in self.layerContents:
1568            directory = self.layerContents[layerName]
1569        # get a  new directory name
1570        else:
1571            if defaultLayer:
1572                directory = DEFAULT_GLYPHS_DIRNAME
1573            else:
1574                # not caching this could be slightly expensive,
1575                # but caching it will be cumbersome
1576                existing = {d.lower() for d in self.layerContents.values()}
1577                directory = userNameToFileName(
1578                    layerName, existing=existing, prefix="glyphs."
1579                )
1580        # make the directory
1581        glyphSubFS = self.fs.makedir(directory, recreate=True)
1582        # store the mapping
1583        self.layerContents[layerName] = directory
1584        # load the glyph set
1585        return GlyphSet(
1586            glyphSubFS,
1587            glyphNameToFileNameFunc=glyphNameToFileNameFunc,
1588            ufoFormatVersion=self._formatVersion,
1589            validateRead=validateRead,
1590            validateWrite=validateWrite,
1591            expectContentsFile=expectContentsFile,
1592        )
1593
1594    def renameGlyphSet(self, layerName, newLayerName, defaultLayer=False):
1595        """
1596        Rename a glyph set.
1597
1598        Note: if a GlyphSet object has already been retrieved for
1599        layerName, it is up to the caller to inform that object that
1600        the directory it represents has changed.
1601        """
1602        if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
1603            # ignore renaming glyph sets for UFO1 UFO2
1604            # just write the data from the default layer
1605            return
1606        # the new and old names can be the same
1607        # as long as the default is being switched
1608        if layerName == newLayerName:
1609            # if the default is off and the layer is already not the default, skip
1610            if (
1611                self.layerContents[layerName] != DEFAULT_GLYPHS_DIRNAME
1612                and not defaultLayer
1613            ):
1614                return
1615            # if the default is on and the layer is already the default, skip
1616            if self.layerContents[layerName] == DEFAULT_GLYPHS_DIRNAME and defaultLayer:
1617                return
1618        else:
1619            # make sure the new layer name doesn't already exist
1620            if newLayerName is None:
1621                newLayerName = DEFAULT_LAYER_NAME
1622            if newLayerName in self.layerContents:
1623                raise UFOLibError("A layer named %s already exists." % newLayerName)
1624            # make sure the default layer doesn't already exist
1625            if defaultLayer and DEFAULT_GLYPHS_DIRNAME in self.layerContents.values():
1626                raise UFOLibError("A default layer already exists.")
1627        # get the paths
1628        oldDirectory = self._findDirectoryForLayerName(layerName)
1629        if defaultLayer:
1630            newDirectory = DEFAULT_GLYPHS_DIRNAME
1631        else:
1632            existing = {name.lower() for name in self.layerContents.values()}
1633            newDirectory = userNameToFileName(
1634                newLayerName, existing=existing, prefix="glyphs."
1635            )
1636        # update the internal mapping
1637        del self.layerContents[layerName]
1638        self.layerContents[newLayerName] = newDirectory
1639        # do the file system copy
1640        self.fs.movedir(oldDirectory, newDirectory, create=True)
1641
1642    def deleteGlyphSet(self, layerName):
1643        """
1644        Remove the glyph set matching layerName.
1645        """
1646        if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
1647            # ignore deleting glyph sets for UFO1 UFO2 as there are no layers
1648            # just write the data from the default layer
1649            return
1650        foundDirectory = self._findDirectoryForLayerName(layerName)
1651        self.removePath(foundDirectory, removeEmptyParents=False)
1652        del self.layerContents[layerName]
1653
1654    def writeData(self, fileName, data):
1655        """
1656        Write data to fileName in the 'data' directory.
1657        The data must be a bytes string.
1658        """
1659        self.writeBytesToPath(f"{DATA_DIRNAME}/{fsdecode(fileName)}", data)
1660
1661    def removeData(self, fileName):
1662        """
1663        Remove the file named fileName from the data directory.
1664        """
1665        self.removePath(f"{DATA_DIRNAME}/{fsdecode(fileName)}")
1666
1667    # /images
1668
1669    def writeImage(self, fileName, data, validate=None):
1670        """
1671        Write data to fileName in the images directory.
1672        The data must be a valid PNG.
1673        """
1674        if validate is None:
1675            validate = self._validate
1676        if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
1677            raise UFOLibError(
1678                f"Images are not allowed in UFO {self._formatVersion.major}."
1679            )
1680        fileName = fsdecode(fileName)
1681        if validate:
1682            valid, error = pngValidator(data=data)
1683            if not valid:
1684                raise UFOLibError(error)
1685        self.writeBytesToPath(f"{IMAGES_DIRNAME}/{fileName}", data)
1686
1687    def removeImage(self, fileName, validate=None):  # XXX remove unused 'validate'?
1688        """
1689        Remove the file named fileName from the
1690        images directory.
1691        """
1692        if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
1693            raise UFOLibError(
1694                f"Images are not allowed in UFO {self._formatVersion.major}."
1695            )
1696        self.removePath(f"{IMAGES_DIRNAME}/{fsdecode(fileName)}")
1697
1698    def copyImageFromReader(self, reader, sourceFileName, destFileName, validate=None):
1699        """
1700        Copy the sourceFileName in the provided UFOReader to destFileName
1701        in this writer. This uses the most memory efficient method possible
1702        for copying the data possible.
1703        """
1704        if validate is None:
1705            validate = self._validate
1706        if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
1707            raise UFOLibError(
1708                f"Images are not allowed in UFO {self._formatVersion.major}."
1709            )
1710        sourcePath = f"{IMAGES_DIRNAME}/{fsdecode(sourceFileName)}"
1711        destPath = f"{IMAGES_DIRNAME}/{fsdecode(destFileName)}"
1712        self.copyFromReader(reader, sourcePath, destPath)
1713
1714    def close(self):
1715        if self._havePreviousFile and self._fileStructure is UFOFileStructure.ZIP:
1716            # if we are updating an existing zip file, we can now compress the
1717            # contents of the temporary filesystem in the destination path
1718            rootDir = os.path.splitext(os.path.basename(self._path))[0] + ".ufo"
1719            with fs.zipfs.ZipFS(self._path, write=True, encoding="utf-8") as destFS:
1720                fs.copy.copy_fs(self.fs, destFS.makedir(rootDir))
1721        super().close()
1722
1723
1724# just an alias, makes it more explicit
1725UFOReaderWriter = UFOWriter
1726
1727
1728# ----------------
1729# Helper Functions
1730# ----------------
1731
1732
1733def _sniffFileStructure(ufo_path):
1734    """Return UFOFileStructure.ZIP if the UFO at path 'ufo_path' (str)
1735    is a zip file, else return UFOFileStructure.PACKAGE if 'ufo_path' is a
1736    directory.
1737    Raise UFOLibError if it is a file with unknown structure, or if the path
1738    does not exist.
1739    """
1740    if zipfile.is_zipfile(ufo_path):
1741        return UFOFileStructure.ZIP
1742    elif os.path.isdir(ufo_path):
1743        return UFOFileStructure.PACKAGE
1744    elif os.path.isfile(ufo_path):
1745        raise UFOLibError(
1746            "The specified UFO does not have a known structure: '%s'" % ufo_path
1747        )
1748    else:
1749        raise UFOLibError("No such file or directory: '%s'" % ufo_path)
1750
1751
1752def makeUFOPath(path):
1753    """
1754    Return a .ufo pathname.
1755
1756    >>> makeUFOPath("directory/something.ext") == (
1757    ... 	os.path.join('directory', 'something.ufo'))
1758    True
1759    >>> makeUFOPath("directory/something.another.thing.ext") == (
1760    ... 	os.path.join('directory', 'something.another.thing.ufo'))
1761    True
1762    """
1763    dir, name = os.path.split(path)
1764    name = ".".join([".".join(name.split(".")[:-1]), "ufo"])
1765    return os.path.join(dir, name)
1766
1767
1768# ----------------------
1769# fontinfo.plist Support
1770# ----------------------
1771
1772# Version Validators
1773
1774# There is no version 1 validator and there shouldn't be.
1775# The version 1 spec was very loose and there were numerous
1776# cases of invalid values.
1777
1778
1779def validateFontInfoVersion2ValueForAttribute(attr, value):
1780    """
1781    This performs very basic validation of the value for attribute
1782    following the UFO 2 fontinfo.plist specification. The results
1783    of this should not be interpretted as *correct* for the font
1784    that they are part of. This merely indicates that the value
1785    is of the proper type and, where the specification defines
1786    a set range of possible values for an attribute, that the
1787    value is in the accepted range.
1788    """
1789    dataValidationDict = fontInfoAttributesVersion2ValueData[attr]
1790    valueType = dataValidationDict.get("type")
1791    validator = dataValidationDict.get("valueValidator")
1792    valueOptions = dataValidationDict.get("valueOptions")
1793    # have specific options for the validator
1794    if valueOptions is not None:
1795        isValidValue = validator(value, valueOptions)
1796    # no specific options
1797    else:
1798        if validator == genericTypeValidator:
1799            isValidValue = validator(value, valueType)
1800        else:
1801            isValidValue = validator(value)
1802    return isValidValue
1803
1804
1805def validateInfoVersion2Data(infoData):
1806    """
1807    This performs very basic validation of the value for infoData
1808    following the UFO 2 fontinfo.plist specification. The results
1809    of this should not be interpretted as *correct* for the font
1810    that they are part of. This merely indicates that the values
1811    are of the proper type and, where the specification defines
1812    a set range of possible values for an attribute, that the
1813    value is in the accepted range.
1814    """
1815    validInfoData = {}
1816    for attr, value in list(infoData.items()):
1817        isValidValue = validateFontInfoVersion2ValueForAttribute(attr, value)
1818        if not isValidValue:
1819            raise UFOLibError(f"Invalid value for attribute {attr} ({value!r}).")
1820        else:
1821            validInfoData[attr] = value
1822    return validInfoData
1823
1824
1825def validateFontInfoVersion3ValueForAttribute(attr, value):
1826    """
1827    This performs very basic validation of the value for attribute
1828    following the UFO 3 fontinfo.plist specification. The results
1829    of this should not be interpretted as *correct* for the font
1830    that they are part of. This merely indicates that the value
1831    is of the proper type and, where the specification defines
1832    a set range of possible values for an attribute, that the
1833    value is in the accepted range.
1834    """
1835    dataValidationDict = fontInfoAttributesVersion3ValueData[attr]
1836    valueType = dataValidationDict.get("type")
1837    validator = dataValidationDict.get("valueValidator")
1838    valueOptions = dataValidationDict.get("valueOptions")
1839    # have specific options for the validator
1840    if valueOptions is not None:
1841        isValidValue = validator(value, valueOptions)
1842    # no specific options
1843    else:
1844        if validator == genericTypeValidator:
1845            isValidValue = validator(value, valueType)
1846        else:
1847            isValidValue = validator(value)
1848    return isValidValue
1849
1850
1851def validateInfoVersion3Data(infoData):
1852    """
1853    This performs very basic validation of the value for infoData
1854    following the UFO 3 fontinfo.plist specification. The results
1855    of this should not be interpretted as *correct* for the font
1856    that they are part of. This merely indicates that the values
1857    are of the proper type and, where the specification defines
1858    a set range of possible values for an attribute, that the
1859    value is in the accepted range.
1860    """
1861    validInfoData = {}
1862    for attr, value in list(infoData.items()):
1863        isValidValue = validateFontInfoVersion3ValueForAttribute(attr, value)
1864        if not isValidValue:
1865            raise UFOLibError(f"Invalid value for attribute {attr} ({value!r}).")
1866        else:
1867            validInfoData[attr] = value
1868    return validInfoData
1869
1870
1871# Value Options
1872
1873fontInfoOpenTypeHeadFlagsOptions = list(range(0, 15))
1874fontInfoOpenTypeOS2SelectionOptions = [1, 2, 3, 4, 7, 8, 9]
1875fontInfoOpenTypeOS2UnicodeRangesOptions = list(range(0, 128))
1876fontInfoOpenTypeOS2CodePageRangesOptions = list(range(0, 64))
1877fontInfoOpenTypeOS2TypeOptions = [0, 1, 2, 3, 8, 9]
1878
1879# Version Attribute Definitions
1880# This defines the attributes, types and, in some
1881# cases the possible values, that can exist is
1882# fontinfo.plist.
1883
1884fontInfoAttributesVersion1 = {
1885    "familyName",
1886    "styleName",
1887    "fullName",
1888    "fontName",
1889    "menuName",
1890    "fontStyle",
1891    "note",
1892    "versionMajor",
1893    "versionMinor",
1894    "year",
1895    "copyright",
1896    "notice",
1897    "trademark",
1898    "license",
1899    "licenseURL",
1900    "createdBy",
1901    "designer",
1902    "designerURL",
1903    "vendorURL",
1904    "unitsPerEm",
1905    "ascender",
1906    "descender",
1907    "capHeight",
1908    "xHeight",
1909    "defaultWidth",
1910    "slantAngle",
1911    "italicAngle",
1912    "widthName",
1913    "weightName",
1914    "weightValue",
1915    "fondName",
1916    "otFamilyName",
1917    "otStyleName",
1918    "otMacName",
1919    "msCharSet",
1920    "fondID",
1921    "uniqueID",
1922    "ttVendor",
1923    "ttUniqueID",
1924    "ttVersion",
1925}
1926
1927fontInfoAttributesVersion2ValueData = {
1928    "familyName": dict(type=str),
1929    "styleName": dict(type=str),
1930    "styleMapFamilyName": dict(type=str),
1931    "styleMapStyleName": dict(
1932        type=str, valueValidator=fontInfoStyleMapStyleNameValidator
1933    ),
1934    "versionMajor": dict(type=int),
1935    "versionMinor": dict(type=int),
1936    "year": dict(type=int),
1937    "copyright": dict(type=str),
1938    "trademark": dict(type=str),
1939    "unitsPerEm": dict(type=(int, float)),
1940    "descender": dict(type=(int, float)),
1941    "xHeight": dict(type=(int, float)),
1942    "capHeight": dict(type=(int, float)),
1943    "ascender": dict(type=(int, float)),
1944    "italicAngle": dict(type=(float, int)),
1945    "note": dict(type=str),
1946    "openTypeHeadCreated": dict(
1947        type=str, valueValidator=fontInfoOpenTypeHeadCreatedValidator
1948    ),
1949    "openTypeHeadLowestRecPPEM": dict(type=(int, float)),
1950    "openTypeHeadFlags": dict(
1951        type="integerList",
1952        valueValidator=genericIntListValidator,
1953        valueOptions=fontInfoOpenTypeHeadFlagsOptions,
1954    ),
1955    "openTypeHheaAscender": dict(type=(int, float)),
1956    "openTypeHheaDescender": dict(type=(int, float)),
1957    "openTypeHheaLineGap": dict(type=(int, float)),
1958    "openTypeHheaCaretSlopeRise": dict(type=int),
1959    "openTypeHheaCaretSlopeRun": dict(type=int),
1960    "openTypeHheaCaretOffset": dict(type=(int, float)),
1961    "openTypeNameDesigner": dict(type=str),
1962    "openTypeNameDesignerURL": dict(type=str),
1963    "openTypeNameManufacturer": dict(type=str),
1964    "openTypeNameManufacturerURL": dict(type=str),
1965    "openTypeNameLicense": dict(type=str),
1966    "openTypeNameLicenseURL": dict(type=str),
1967    "openTypeNameVersion": dict(type=str),
1968    "openTypeNameUniqueID": dict(type=str),
1969    "openTypeNameDescription": dict(type=str),
1970    "openTypeNamePreferredFamilyName": dict(type=str),
1971    "openTypeNamePreferredSubfamilyName": dict(type=str),
1972    "openTypeNameCompatibleFullName": dict(type=str),
1973    "openTypeNameSampleText": dict(type=str),
1974    "openTypeNameWWSFamilyName": dict(type=str),
1975    "openTypeNameWWSSubfamilyName": dict(type=str),
1976    "openTypeOS2WidthClass": dict(
1977        type=int, valueValidator=fontInfoOpenTypeOS2WidthClassValidator
1978    ),
1979    "openTypeOS2WeightClass": dict(
1980        type=int, valueValidator=fontInfoOpenTypeOS2WeightClassValidator
1981    ),
1982    "openTypeOS2Selection": dict(
1983        type="integerList",
1984        valueValidator=genericIntListValidator,
1985        valueOptions=fontInfoOpenTypeOS2SelectionOptions,
1986    ),
1987    "openTypeOS2VendorID": dict(type=str),
1988    "openTypeOS2Panose": dict(
1989        type="integerList", valueValidator=fontInfoVersion2OpenTypeOS2PanoseValidator
1990    ),
1991    "openTypeOS2FamilyClass": dict(
1992        type="integerList", valueValidator=fontInfoOpenTypeOS2FamilyClassValidator
1993    ),
1994    "openTypeOS2UnicodeRanges": dict(
1995        type="integerList",
1996        valueValidator=genericIntListValidator,
1997        valueOptions=fontInfoOpenTypeOS2UnicodeRangesOptions,
1998    ),
1999    "openTypeOS2CodePageRanges": dict(
2000        type="integerList",
2001        valueValidator=genericIntListValidator,
2002        valueOptions=fontInfoOpenTypeOS2CodePageRangesOptions,
2003    ),
2004    "openTypeOS2TypoAscender": dict(type=(int, float)),
2005    "openTypeOS2TypoDescender": dict(type=(int, float)),
2006    "openTypeOS2TypoLineGap": dict(type=(int, float)),
2007    "openTypeOS2WinAscent": dict(type=(int, float)),
2008    "openTypeOS2WinDescent": dict(type=(int, float)),
2009    "openTypeOS2Type": dict(
2010        type="integerList",
2011        valueValidator=genericIntListValidator,
2012        valueOptions=fontInfoOpenTypeOS2TypeOptions,
2013    ),
2014    "openTypeOS2SubscriptXSize": dict(type=(int, float)),
2015    "openTypeOS2SubscriptYSize": dict(type=(int, float)),
2016    "openTypeOS2SubscriptXOffset": dict(type=(int, float)),
2017    "openTypeOS2SubscriptYOffset": dict(type=(int, float)),
2018    "openTypeOS2SuperscriptXSize": dict(type=(int, float)),
2019    "openTypeOS2SuperscriptYSize": dict(type=(int, float)),
2020    "openTypeOS2SuperscriptXOffset": dict(type=(int, float)),
2021    "openTypeOS2SuperscriptYOffset": dict(type=(int, float)),
2022    "openTypeOS2StrikeoutSize": dict(type=(int, float)),
2023    "openTypeOS2StrikeoutPosition": dict(type=(int, float)),
2024    "openTypeVheaVertTypoAscender": dict(type=(int, float)),
2025    "openTypeVheaVertTypoDescender": dict(type=(int, float)),
2026    "openTypeVheaVertTypoLineGap": dict(type=(int, float)),
2027    "openTypeVheaCaretSlopeRise": dict(type=int),
2028    "openTypeVheaCaretSlopeRun": dict(type=int),
2029    "openTypeVheaCaretOffset": dict(type=(int, float)),
2030    "postscriptFontName": dict(type=str),
2031    "postscriptFullName": dict(type=str),
2032    "postscriptSlantAngle": dict(type=(float, int)),
2033    "postscriptUniqueID": dict(type=int),
2034    "postscriptUnderlineThickness": dict(type=(int, float)),
2035    "postscriptUnderlinePosition": dict(type=(int, float)),
2036    "postscriptIsFixedPitch": dict(type=bool),
2037    "postscriptBlueValues": dict(
2038        type="integerList", valueValidator=fontInfoPostscriptBluesValidator
2039    ),
2040    "postscriptOtherBlues": dict(
2041        type="integerList", valueValidator=fontInfoPostscriptOtherBluesValidator
2042    ),
2043    "postscriptFamilyBlues": dict(
2044        type="integerList", valueValidator=fontInfoPostscriptBluesValidator
2045    ),
2046    "postscriptFamilyOtherBlues": dict(
2047        type="integerList", valueValidator=fontInfoPostscriptOtherBluesValidator
2048    ),
2049    "postscriptStemSnapH": dict(
2050        type="integerList", valueValidator=fontInfoPostscriptStemsValidator
2051    ),
2052    "postscriptStemSnapV": dict(
2053        type="integerList", valueValidator=fontInfoPostscriptStemsValidator
2054    ),
2055    "postscriptBlueFuzz": dict(type=(int, float)),
2056    "postscriptBlueShift": dict(type=(int, float)),
2057    "postscriptBlueScale": dict(type=(float, int)),
2058    "postscriptForceBold": dict(type=bool),
2059    "postscriptDefaultWidthX": dict(type=(int, float)),
2060    "postscriptNominalWidthX": dict(type=(int, float)),
2061    "postscriptWeightName": dict(type=str),
2062    "postscriptDefaultCharacter": dict(type=str),
2063    "postscriptWindowsCharacterSet": dict(
2064        type=int, valueValidator=fontInfoPostscriptWindowsCharacterSetValidator
2065    ),
2066    "macintoshFONDFamilyID": dict(type=int),
2067    "macintoshFONDName": dict(type=str),
2068}
2069fontInfoAttributesVersion2 = set(fontInfoAttributesVersion2ValueData.keys())
2070
2071fontInfoAttributesVersion3ValueData = deepcopy(fontInfoAttributesVersion2ValueData)
2072fontInfoAttributesVersion3ValueData.update(
2073    {
2074        "versionMinor": dict(type=int, valueValidator=genericNonNegativeIntValidator),
2075        "unitsPerEm": dict(
2076            type=(int, float), valueValidator=genericNonNegativeNumberValidator
2077        ),
2078        "openTypeHeadLowestRecPPEM": dict(
2079            type=int, valueValidator=genericNonNegativeNumberValidator
2080        ),
2081        "openTypeHheaAscender": dict(type=int),
2082        "openTypeHheaDescender": dict(type=int),
2083        "openTypeHheaLineGap": dict(type=int),
2084        "openTypeHheaCaretOffset": dict(type=int),
2085        "openTypeOS2Panose": dict(
2086            type="integerList",
2087            valueValidator=fontInfoVersion3OpenTypeOS2PanoseValidator,
2088        ),
2089        "openTypeOS2TypoAscender": dict(type=int),
2090        "openTypeOS2TypoDescender": dict(type=int),
2091        "openTypeOS2TypoLineGap": dict(type=int),
2092        "openTypeOS2WinAscent": dict(
2093            type=int, valueValidator=genericNonNegativeNumberValidator
2094        ),
2095        "openTypeOS2WinDescent": dict(
2096            type=int, valueValidator=genericNonNegativeNumberValidator
2097        ),
2098        "openTypeOS2SubscriptXSize": dict(type=int),
2099        "openTypeOS2SubscriptYSize": dict(type=int),
2100        "openTypeOS2SubscriptXOffset": dict(type=int),
2101        "openTypeOS2SubscriptYOffset": dict(type=int),
2102        "openTypeOS2SuperscriptXSize": dict(type=int),
2103        "openTypeOS2SuperscriptYSize": dict(type=int),
2104        "openTypeOS2SuperscriptXOffset": dict(type=int),
2105        "openTypeOS2SuperscriptYOffset": dict(type=int),
2106        "openTypeOS2StrikeoutSize": dict(type=int),
2107        "openTypeOS2StrikeoutPosition": dict(type=int),
2108        "openTypeGaspRangeRecords": dict(
2109            type="dictList", valueValidator=fontInfoOpenTypeGaspRangeRecordsValidator
2110        ),
2111        "openTypeNameRecords": dict(
2112            type="dictList", valueValidator=fontInfoOpenTypeNameRecordsValidator
2113        ),
2114        "openTypeVheaVertTypoAscender": dict(type=int),
2115        "openTypeVheaVertTypoDescender": dict(type=int),
2116        "openTypeVheaVertTypoLineGap": dict(type=int),
2117        "openTypeVheaCaretOffset": dict(type=int),
2118        "woffMajorVersion": dict(
2119            type=int, valueValidator=genericNonNegativeIntValidator
2120        ),
2121        "woffMinorVersion": dict(
2122            type=int, valueValidator=genericNonNegativeIntValidator
2123        ),
2124        "woffMetadataUniqueID": dict(
2125            type=dict, valueValidator=fontInfoWOFFMetadataUniqueIDValidator
2126        ),
2127        "woffMetadataVendor": dict(
2128            type=dict, valueValidator=fontInfoWOFFMetadataVendorValidator
2129        ),
2130        "woffMetadataCredits": dict(
2131            type=dict, valueValidator=fontInfoWOFFMetadataCreditsValidator
2132        ),
2133        "woffMetadataDescription": dict(
2134            type=dict, valueValidator=fontInfoWOFFMetadataDescriptionValidator
2135        ),
2136        "woffMetadataLicense": dict(
2137            type=dict, valueValidator=fontInfoWOFFMetadataLicenseValidator
2138        ),
2139        "woffMetadataCopyright": dict(
2140            type=dict, valueValidator=fontInfoWOFFMetadataCopyrightValidator
2141        ),
2142        "woffMetadataTrademark": dict(
2143            type=dict, valueValidator=fontInfoWOFFMetadataTrademarkValidator
2144        ),
2145        "woffMetadataLicensee": dict(
2146            type=dict, valueValidator=fontInfoWOFFMetadataLicenseeValidator
2147        ),
2148        "woffMetadataExtensions": dict(
2149            type=list, valueValidator=fontInfoWOFFMetadataExtensionsValidator
2150        ),
2151        "guidelines": dict(type=list, valueValidator=guidelinesValidator),
2152    }
2153)
2154fontInfoAttributesVersion3 = set(fontInfoAttributesVersion3ValueData.keys())
2155
2156# insert the type validator for all attrs that
2157# have no defined validator.
2158for attr, dataDict in list(fontInfoAttributesVersion2ValueData.items()):
2159    if "valueValidator" not in dataDict:
2160        dataDict["valueValidator"] = genericTypeValidator
2161
2162for attr, dataDict in list(fontInfoAttributesVersion3ValueData.items()):
2163    if "valueValidator" not in dataDict:
2164        dataDict["valueValidator"] = genericTypeValidator
2165
2166# Version Conversion Support
2167# These are used from converting from version 1
2168# to version 2 or vice-versa.
2169
2170
2171def _flipDict(d):
2172    flipped = {}
2173    for key, value in list(d.items()):
2174        flipped[value] = key
2175    return flipped
2176
2177
2178fontInfoAttributesVersion1To2 = {
2179    "menuName": "styleMapFamilyName",
2180    "designer": "openTypeNameDesigner",
2181    "designerURL": "openTypeNameDesignerURL",
2182    "createdBy": "openTypeNameManufacturer",
2183    "vendorURL": "openTypeNameManufacturerURL",
2184    "license": "openTypeNameLicense",
2185    "licenseURL": "openTypeNameLicenseURL",
2186    "ttVersion": "openTypeNameVersion",
2187    "ttUniqueID": "openTypeNameUniqueID",
2188    "notice": "openTypeNameDescription",
2189    "otFamilyName": "openTypeNamePreferredFamilyName",
2190    "otStyleName": "openTypeNamePreferredSubfamilyName",
2191    "otMacName": "openTypeNameCompatibleFullName",
2192    "weightName": "postscriptWeightName",
2193    "weightValue": "openTypeOS2WeightClass",
2194    "ttVendor": "openTypeOS2VendorID",
2195    "uniqueID": "postscriptUniqueID",
2196    "fontName": "postscriptFontName",
2197    "fondID": "macintoshFONDFamilyID",
2198    "fondName": "macintoshFONDName",
2199    "defaultWidth": "postscriptDefaultWidthX",
2200    "slantAngle": "postscriptSlantAngle",
2201    "fullName": "postscriptFullName",
2202    # require special value conversion
2203    "fontStyle": "styleMapStyleName",
2204    "widthName": "openTypeOS2WidthClass",
2205    "msCharSet": "postscriptWindowsCharacterSet",
2206}
2207fontInfoAttributesVersion2To1 = _flipDict(fontInfoAttributesVersion1To2)
2208deprecatedFontInfoAttributesVersion2 = set(fontInfoAttributesVersion1To2.keys())
2209
2210_fontStyle1To2 = {64: "regular", 1: "italic", 32: "bold", 33: "bold italic"}
2211_fontStyle2To1 = _flipDict(_fontStyle1To2)
2212# Some UFO 1 files have 0
2213_fontStyle1To2[0] = "regular"
2214
2215_widthName1To2 = {
2216    "Ultra-condensed": 1,
2217    "Extra-condensed": 2,
2218    "Condensed": 3,
2219    "Semi-condensed": 4,
2220    "Medium (normal)": 5,
2221    "Semi-expanded": 6,
2222    "Expanded": 7,
2223    "Extra-expanded": 8,
2224    "Ultra-expanded": 9,
2225}
2226_widthName2To1 = _flipDict(_widthName1To2)
2227# FontLab's default width value is "Normal".
2228# Many format version 1 UFOs will have this.
2229_widthName1To2["Normal"] = 5
2230# FontLab has an "All" width value. In UFO 1
2231# move this up to "Normal".
2232_widthName1To2["All"] = 5
2233# "medium" appears in a lot of UFO 1 files.
2234_widthName1To2["medium"] = 5
2235# "Medium" appears in a lot of UFO 1 files.
2236_widthName1To2["Medium"] = 5
2237
2238_msCharSet1To2 = {
2239    0: 1,
2240    1: 2,
2241    2: 3,
2242    77: 4,
2243    128: 5,
2244    129: 6,
2245    130: 7,
2246    134: 8,
2247    136: 9,
2248    161: 10,
2249    162: 11,
2250    163: 12,
2251    177: 13,
2252    178: 14,
2253    186: 15,
2254    200: 16,
2255    204: 17,
2256    222: 18,
2257    238: 19,
2258    255: 20,
2259}
2260_msCharSet2To1 = _flipDict(_msCharSet1To2)
2261
2262# 1 <-> 2
2263
2264
2265def convertFontInfoValueForAttributeFromVersion1ToVersion2(attr, value):
2266    """
2267    Convert value from version 1 to version 2 format.
2268    Returns the new attribute name and the converted value.
2269    If the value is None, None will be returned for the new value.
2270    """
2271    # convert floats to ints if possible
2272    if isinstance(value, float):
2273        if int(value) == value:
2274            value = int(value)
2275    if value is not None:
2276        if attr == "fontStyle":
2277            v = _fontStyle1To2.get(value)
2278            if v is None:
2279                raise UFOLibError(
2280                    f"Cannot convert value ({value!r}) for attribute {attr}."
2281                )
2282            value = v
2283        elif attr == "widthName":
2284            v = _widthName1To2.get(value)
2285            if v is None:
2286                raise UFOLibError(
2287                    f"Cannot convert value ({value!r}) for attribute {attr}."
2288                )
2289            value = v
2290        elif attr == "msCharSet":
2291            v = _msCharSet1To2.get(value)
2292            if v is None:
2293                raise UFOLibError(
2294                    f"Cannot convert value ({value!r}) for attribute {attr}."
2295                )
2296            value = v
2297    attr = fontInfoAttributesVersion1To2.get(attr, attr)
2298    return attr, value
2299
2300
2301def convertFontInfoValueForAttributeFromVersion2ToVersion1(attr, value):
2302    """
2303    Convert value from version 2 to version 1 format.
2304    Returns the new attribute name and the converted value.
2305    If the value is None, None will be returned for the new value.
2306    """
2307    if value is not None:
2308        if attr == "styleMapStyleName":
2309            value = _fontStyle2To1.get(value)
2310        elif attr == "openTypeOS2WidthClass":
2311            value = _widthName2To1.get(value)
2312        elif attr == "postscriptWindowsCharacterSet":
2313            value = _msCharSet2To1.get(value)
2314    attr = fontInfoAttributesVersion2To1.get(attr, attr)
2315    return attr, value
2316
2317
2318def _convertFontInfoDataVersion1ToVersion2(data):
2319    converted = {}
2320    for attr, value in list(data.items()):
2321        # FontLab gives -1 for the weightValue
2322        # for fonts wil no defined value. Many
2323        # format version 1 UFOs will have this.
2324        if attr == "weightValue" and value == -1:
2325            continue
2326        newAttr, newValue = convertFontInfoValueForAttributeFromVersion1ToVersion2(
2327            attr, value
2328        )
2329        # skip if the attribute is not part of version 2
2330        if newAttr not in fontInfoAttributesVersion2:
2331            continue
2332        # catch values that can't be converted
2333        if value is None:
2334            raise UFOLibError(
2335                f"Cannot convert value ({value!r}) for attribute {newAttr}."
2336            )
2337        # store
2338        converted[newAttr] = newValue
2339    return converted
2340
2341
2342def _convertFontInfoDataVersion2ToVersion1(data):
2343    converted = {}
2344    for attr, value in list(data.items()):
2345        newAttr, newValue = convertFontInfoValueForAttributeFromVersion2ToVersion1(
2346            attr, value
2347        )
2348        # only take attributes that are registered for version 1
2349        if newAttr not in fontInfoAttributesVersion1:
2350            continue
2351        # catch values that can't be converted
2352        if value is None:
2353            raise UFOLibError(
2354                f"Cannot convert value ({value!r}) for attribute {newAttr}."
2355            )
2356        # store
2357        converted[newAttr] = newValue
2358    return converted
2359
2360
2361# 2 <-> 3
2362
2363_ufo2To3NonNegativeInt = {
2364    "versionMinor",
2365    "openTypeHeadLowestRecPPEM",
2366    "openTypeOS2WinAscent",
2367    "openTypeOS2WinDescent",
2368}
2369_ufo2To3NonNegativeIntOrFloat = {
2370    "unitsPerEm",
2371}
2372_ufo2To3FloatToInt = {
2373    "openTypeHeadLowestRecPPEM",
2374    "openTypeHheaAscender",
2375    "openTypeHheaDescender",
2376    "openTypeHheaLineGap",
2377    "openTypeHheaCaretOffset",
2378    "openTypeOS2TypoAscender",
2379    "openTypeOS2TypoDescender",
2380    "openTypeOS2TypoLineGap",
2381    "openTypeOS2WinAscent",
2382    "openTypeOS2WinDescent",
2383    "openTypeOS2SubscriptXSize",
2384    "openTypeOS2SubscriptYSize",
2385    "openTypeOS2SubscriptXOffset",
2386    "openTypeOS2SubscriptYOffset",
2387    "openTypeOS2SuperscriptXSize",
2388    "openTypeOS2SuperscriptYSize",
2389    "openTypeOS2SuperscriptXOffset",
2390    "openTypeOS2SuperscriptYOffset",
2391    "openTypeOS2StrikeoutSize",
2392    "openTypeOS2StrikeoutPosition",
2393    "openTypeVheaVertTypoAscender",
2394    "openTypeVheaVertTypoDescender",
2395    "openTypeVheaVertTypoLineGap",
2396    "openTypeVheaCaretOffset",
2397}
2398
2399
2400def convertFontInfoValueForAttributeFromVersion2ToVersion3(attr, value):
2401    """
2402    Convert value from version 2 to version 3 format.
2403    Returns the new attribute name and the converted value.
2404    If the value is None, None will be returned for the new value.
2405    """
2406    if attr in _ufo2To3FloatToInt:
2407        try:
2408            value = round(value)
2409        except (ValueError, TypeError):
2410            raise UFOLibError("Could not convert value for %s." % attr)
2411    if attr in _ufo2To3NonNegativeInt:
2412        try:
2413            value = int(abs(value))
2414        except (ValueError, TypeError):
2415            raise UFOLibError("Could not convert value for %s." % attr)
2416    elif attr in _ufo2To3NonNegativeIntOrFloat:
2417        try:
2418            v = float(abs(value))
2419        except (ValueError, TypeError):
2420            raise UFOLibError("Could not convert value for %s." % attr)
2421        if v == int(v):
2422            v = int(v)
2423        if v != value:
2424            value = v
2425    return attr, value
2426
2427
2428def convertFontInfoValueForAttributeFromVersion3ToVersion2(attr, value):
2429    """
2430    Convert value from version 3 to version 2 format.
2431    Returns the new attribute name and the converted value.
2432    If the value is None, None will be returned for the new value.
2433    """
2434    return attr, value
2435
2436
2437def _convertFontInfoDataVersion3ToVersion2(data):
2438    converted = {}
2439    for attr, value in list(data.items()):
2440        newAttr, newValue = convertFontInfoValueForAttributeFromVersion3ToVersion2(
2441            attr, value
2442        )
2443        if newAttr not in fontInfoAttributesVersion2:
2444            continue
2445        converted[newAttr] = newValue
2446    return converted
2447
2448
2449def _convertFontInfoDataVersion2ToVersion3(data):
2450    converted = {}
2451    for attr, value in list(data.items()):
2452        attr, value = convertFontInfoValueForAttributeFromVersion2ToVersion3(
2453            attr, value
2454        )
2455        converted[attr] = value
2456    return converted
2457
2458
2459if __name__ == "__main__":
2460    import doctest
2461
2462    doctest.testmod()
2463