diff options
Diffstat (limited to 'Lib/fontTools/ufoLib/__init__.py')
-rwxr-xr-x | Lib/fontTools/ufoLib/__init__.py | 4301 |
1 files changed, 2226 insertions, 2075 deletions
diff --git a/Lib/fontTools/ufoLib/__init__.py b/Lib/fontTools/ufoLib/__init__.py index fa6cb117..1a456a20 100755 --- a/Lib/fontTools/ufoLib/__init__.py +++ b/Lib/fontTools/ufoLib/__init__.py @@ -52,20 +52,20 @@ fontinfo.plist values between the possible format versions. """ __all__ = [ - "makeUFOPath", - "UFOLibError", - "UFOReader", - "UFOWriter", - "UFOReaderWriter", - "UFOFileStructure", - "fontInfoAttributesVersion1", - "fontInfoAttributesVersion2", - "fontInfoAttributesVersion3", - "deprecatedFontInfoAttributesVersion2", - "validateFontInfoVersion2ValueForAttribute", - "validateFontInfoVersion3ValueForAttribute", - "convertFontInfoValueForAttributeFromVersion1ToVersion2", - "convertFontInfoValueForAttributeFromVersion2ToVersion1" + "makeUFOPath", + "UFOLibError", + "UFOReader", + "UFOWriter", + "UFOReaderWriter", + "UFOFileStructure", + "fontInfoAttributesVersion1", + "fontInfoAttributesVersion2", + "fontInfoAttributesVersion3", + "deprecatedFontInfoAttributesVersion2", + "validateFontInfoVersion2ValueForAttribute", + "validateFontInfoVersion3ValueForAttribute", + "convertFontInfoValueForAttributeFromVersion1ToVersion2", + "convertFontInfoValueForAttributeFromVersion2ToVersion1", ] __version__ = "3.0.0" @@ -94,9 +94,10 @@ DEFAULT_LAYER_NAME = "public.default" class UFOFormatVersion(tuple, _VersionTupleEnumMixin, enum.Enum): - FORMAT_1_0 = (1, 0) - FORMAT_2_0 = (2, 0) - FORMAT_3_0 = (3, 0) + FORMAT_1_0 = (1, 0) + FORMAT_2_0 = (2, 0) + FORMAT_3_0 = (3, 0) + # python 3.11 doesn't like when a mixin overrides a dunder method like __str__ # for some reasons it keep using Enum.__str__, see @@ -105,8 +106,8 @@ UFOFormatVersion.__str__ = _VersionTupleEnumMixin.__str__ class UFOFileStructure(enum.Enum): - ZIP = "zip" - PACKAGE = "package" + ZIP = "zip" + PACKAGE = "package" # -------------- @@ -115,1578 +116,1611 @@ class UFOFileStructure(enum.Enum): class _UFOBaseIO: - - def getFileModificationTime(self, path): - """ - Returns the modification time for the file at the given path, as a - floating point number giving the number of seconds since the epoch. - The path must be relative to the UFO path. - Returns None if the file does not exist. - """ - try: - dt = self.fs.getinfo(fsdecode(path), namespaces=["details"]).modified - except (fs.errors.MissingInfoNamespace, fs.errors.ResourceNotFound): - return None - else: - return dt.timestamp() - - def _getPlist(self, fileName, default=None): - """ - Read a property list relative to the UFO filesystem's root. - Raises UFOLibError if the file is missing and default is None, - otherwise default is returned. - - The errors that could be raised during the reading of a plist are - unpredictable and/or too large to list, so, a blind try: except: - is done. If an exception occurs, a UFOLibError will be raised. - """ - try: - with self.fs.open(fileName, "rb") as f: - return plistlib.load(f) - except fs.errors.ResourceNotFound: - if default is None: - raise UFOLibError( - "'%s' is missing on %s. This file is required" - % (fileName, self.fs) - ) - else: - return default - except Exception as e: - # TODO(anthrotype): try to narrow this down a little - raise UFOLibError( - f"'{fileName}' could not be read on {self.fs}: {e}" - ) - - def _writePlist(self, fileName, obj): - """ - Write a property list to a file relative to the UFO filesystem's root. - - Do this sort of atomically, making it harder to corrupt existing files, - for example when plistlib encounters an error halfway during write. - This also checks to see if text matches the text that is already in the - file at path. If so, the file is not rewritten so that the modification - date is preserved. - - The errors that could be raised during the writing of a plist are - unpredictable and/or too large to list, so, a blind try: except: is done. - If an exception occurs, a UFOLibError will be raised. - """ - if self._havePreviousFile: - try: - data = plistlib.dumps(obj) - except Exception as e: - raise UFOLibError( - "'%s' could not be written on %s because " - "the data is not properly formatted: %s" - % (fileName, self.fs, e) - ) - if self.fs.exists(fileName) and data == self.fs.readbytes(fileName): - return - self.fs.writebytes(fileName, data) - else: - with self.fs.openbin(fileName, mode="w") as fp: - try: - plistlib.dump(obj, fp) - except Exception as e: - raise UFOLibError( - "'%s' could not be written on %s because " - "the data is not properly formatted: %s" - % (fileName, self.fs, e) - ) + def getFileModificationTime(self, path): + """ + Returns the modification time for the file at the given path, as a + floating point number giving the number of seconds since the epoch. + The path must be relative to the UFO path. + Returns None if the file does not exist. + """ + try: + dt = self.fs.getinfo(fsdecode(path), namespaces=["details"]).modified + except (fs.errors.MissingInfoNamespace, fs.errors.ResourceNotFound): + return None + else: + return dt.timestamp() + + def _getPlist(self, fileName, default=None): + """ + Read a property list relative to the UFO filesystem's root. + Raises UFOLibError if the file is missing and default is None, + otherwise default is returned. + + The errors that could be raised during the reading of a plist are + unpredictable and/or too large to list, so, a blind try: except: + is done. If an exception occurs, a UFOLibError will be raised. + """ + try: + with self.fs.open(fileName, "rb") as f: + return plistlib.load(f) + except fs.errors.ResourceNotFound: + if default is None: + raise UFOLibError( + "'%s' is missing on %s. This file is required" % (fileName, self.fs) + ) + else: + return default + except Exception as e: + # TODO(anthrotype): try to narrow this down a little + raise UFOLibError(f"'{fileName}' could not be read on {self.fs}: {e}") + + def _writePlist(self, fileName, obj): + """ + Write a property list to a file relative to the UFO filesystem's root. + + Do this sort of atomically, making it harder to corrupt existing files, + for example when plistlib encounters an error halfway during write. + This also checks to see if text matches the text that is already in the + file at path. If so, the file is not rewritten so that the modification + date is preserved. + + The errors that could be raised during the writing of a plist are + unpredictable and/or too large to list, so, a blind try: except: is done. + If an exception occurs, a UFOLibError will be raised. + """ + if self._havePreviousFile: + try: + data = plistlib.dumps(obj) + except Exception as e: + raise UFOLibError( + "'%s' could not be written on %s because " + "the data is not properly formatted: %s" % (fileName, self.fs, e) + ) + if self.fs.exists(fileName) and data == self.fs.readbytes(fileName): + return + self.fs.writebytes(fileName, data) + else: + with self.fs.openbin(fileName, mode="w") as fp: + try: + plistlib.dump(obj, fp) + except Exception as e: + raise UFOLibError( + "'%s' could not be written on %s because " + "the data is not properly formatted: %s" + % (fileName, self.fs, e) + ) # ---------- # UFO Reader # ---------- + class UFOReader(_UFOBaseIO): - """ - Read the various components of the .ufo. - - By default read data is validated. Set ``validate`` to - ``False`` to not validate the data. - """ - - def __init__(self, path, validate=True): - if hasattr(path, "__fspath__"): # support os.PathLike objects - path = path.__fspath__() - - if isinstance(path, str): - structure = _sniffFileStructure(path) - try: - if structure is UFOFileStructure.ZIP: - parentFS = fs.zipfs.ZipFS(path, write=False, encoding="utf-8") - else: - parentFS = fs.osfs.OSFS(path) - except fs.errors.CreateFailed as e: - raise UFOLibError(f"unable to open '{path}': {e}") - - if structure is UFOFileStructure.ZIP: - # .ufoz zip files must contain a single root directory, with arbitrary - # name, containing all the UFO files - rootDirs = [ - p.name for p in parentFS.scandir("/") - # exclude macOS metadata contained in zip file - if p.is_dir and p.name != "__MACOSX" - ] - if len(rootDirs) == 1: - # 'ClosingSubFS' ensures that the parent zip file is closed when - # its root subdirectory is closed - self.fs = parentFS.opendir( - rootDirs[0], factory=fs.subfs.ClosingSubFS - ) - else: - raise UFOLibError( - "Expected exactly 1 root directory, found %d" % len(rootDirs) - ) - else: - # normal UFO 'packages' are just a single folder - self.fs = parentFS - # when passed a path string, we make sure we close the newly opened fs - # upon calling UFOReader.close method or context manager's __exit__ - self._shouldClose = True - self._fileStructure = structure - elif isinstance(path, fs.base.FS): - filesystem = path - try: - filesystem.check() - except fs.errors.FilesystemClosed: - raise UFOLibError("the filesystem '%s' is closed" % path) - else: - self.fs = filesystem - try: - path = filesystem.getsyspath("/") - except fs.errors.NoSysPath: - # network or in-memory FS may not map to the local one - path = str(filesystem) - # when user passed an already initialized fs instance, it is her - # responsibility to close it, thus UFOReader.close/__exit__ are no-op - self._shouldClose = False - # default to a 'package' structure - self._fileStructure = UFOFileStructure.PACKAGE - else: - raise TypeError( - "Expected a path string or fs.base.FS object, found '%s'" - % type(path).__name__ - ) - self._path = fsdecode(path) - self._validate = validate - self._upConvertedKerningData = None - - try: - self.readMetaInfo(validate=validate) - except UFOLibError: - self.close() - raise - - # properties - - def _get_path(self): - import warnings - - warnings.warn( - "The 'path' attribute is deprecated; use the 'fs' attribute instead", - DeprecationWarning, - stacklevel=2, - ) - return self._path - - path = property(_get_path, doc="The path of the UFO (DEPRECATED).") - - def _get_formatVersion(self): - import warnings - - warnings.warn( - "The 'formatVersion' attribute is deprecated; use the 'formatVersionTuple'", - DeprecationWarning, - stacklevel=2, - ) - return self._formatVersion.major - - formatVersion = property( - _get_formatVersion, - doc="The (major) format version of the UFO. DEPRECATED: Use formatVersionTuple" - ) - - @property - def formatVersionTuple(self): - """The (major, minor) format version of the UFO. - This is determined by reading metainfo.plist during __init__. - """ - return self._formatVersion - - def _get_fileStructure(self): - return self._fileStructure - - fileStructure = property( - _get_fileStructure, - doc=( - "The file structure of the UFO: " - "either UFOFileStructure.ZIP or UFOFileStructure.PACKAGE" - ) - ) - - # up conversion - - def _upConvertKerning(self, validate): - """ - Up convert kerning and groups in UFO 1 and 2. - The data will be held internally until each bit of data - has been retrieved. The conversion of both must be done - at once, so the raw data is cached and an error is raised - if one bit of data becomes obsolete before it is called. - - ``validate`` will validate the data. - """ - if self._upConvertedKerningData: - testKerning = self._readKerning() - if testKerning != self._upConvertedKerningData["originalKerning"]: - raise UFOLibError("The data in kerning.plist has been modified since it was converted to UFO 3 format.") - testGroups = self._readGroups() - if testGroups != self._upConvertedKerningData["originalGroups"]: - raise UFOLibError("The data in groups.plist has been modified since it was converted to UFO 3 format.") - else: - groups = self._readGroups() - if validate: - invalidFormatMessage = "groups.plist is not properly formatted." - if not isinstance(groups, dict): - raise UFOLibError(invalidFormatMessage) - for groupName, glyphList in groups.items(): - if not isinstance(groupName, str): - raise UFOLibError(invalidFormatMessage) - elif not isinstance(glyphList, list): - raise UFOLibError(invalidFormatMessage) - for glyphName in glyphList: - if not isinstance(glyphName, str): - raise UFOLibError(invalidFormatMessage) - self._upConvertedKerningData = dict( - kerning={}, - originalKerning=self._readKerning(), - groups={}, - originalGroups=groups - ) - # convert kerning and groups - kerning, groups, conversionMaps = convertUFO1OrUFO2KerningToUFO3Kerning( - self._upConvertedKerningData["originalKerning"], - deepcopy(self._upConvertedKerningData["originalGroups"]), - self.getGlyphSet() - ) - # store - self._upConvertedKerningData["kerning"] = kerning - self._upConvertedKerningData["groups"] = groups - self._upConvertedKerningData["groupRenameMaps"] = conversionMaps - - # support methods - - def readBytesFromPath(self, path): - """ - Returns the bytes in the file at the given path. - The path must be relative to the UFO's filesystem root. - Returns None if the file does not exist. - """ - try: - return self.fs.readbytes(fsdecode(path)) - except fs.errors.ResourceNotFound: - return None - - def getReadFileForPath(self, path, encoding=None): - """ - Returns a file (or file-like) object for the file at the given path. - The path must be relative to the UFO path. - Returns None if the file does not exist. - By default the file is opened in binary mode (reads bytes). - If encoding is passed, the file is opened in text mode (reads str). - - Note: The caller is responsible for closing the open file. - """ - path = fsdecode(path) - try: - if encoding is None: - return self.fs.openbin(path) - else: - return self.fs.open(path, mode="r", encoding=encoding) - except fs.errors.ResourceNotFound: - return None - # metainfo.plist - - def _readMetaInfo(self, validate=None): - """ - Read metainfo.plist and return raw data. Only used for internal operations. - - ``validate`` will validate the read data, by default it is set - to the class's validate value, can be overridden. - """ - if validate is None: - validate = self._validate - data = self._getPlist(METAINFO_FILENAME) - if validate and not isinstance(data, dict): - raise UFOLibError("metainfo.plist is not properly formatted.") - try: - formatVersionMajor = data["formatVersion"] - except KeyError: - raise UFOLibError( - f"Missing required formatVersion in '{METAINFO_FILENAME}' on {self.fs}" - ) - formatVersionMinor = data.setdefault("formatVersionMinor", 0) - - try: - formatVersion = UFOFormatVersion((formatVersionMajor, formatVersionMinor)) - except ValueError as e: - unsupportedMsg = ( - f"Unsupported UFO format ({formatVersionMajor}.{formatVersionMinor}) " - f"in '{METAINFO_FILENAME}' on {self.fs}" - ) - if validate: - from fontTools.ufoLib.errors import UnsupportedUFOFormat - - raise UnsupportedUFOFormat(unsupportedMsg) from e - - formatVersion = UFOFormatVersion.default() - logger.warning( - "%s. Assuming the latest supported version (%s). " - "Some data may be skipped or parsed incorrectly", - unsupportedMsg, formatVersion - ) - data["formatVersionTuple"] = formatVersion - return data - - def readMetaInfo(self, validate=None): - """ - Read metainfo.plist and set formatVersion. Only used for internal operations. - - ``validate`` will validate the read data, by default it is set - to the class's validate value, can be overridden. - """ - data = self._readMetaInfo(validate=validate) - self._formatVersion = data["formatVersionTuple"] - - # groups.plist - - def _readGroups(self): - groups = self._getPlist(GROUPS_FILENAME, {}) - # remove any duplicate glyphs in a kerning group - for groupName, glyphList in groups.items(): - if groupName.startswith(('public.kern1.', 'public.kern2.')): - groups[groupName] = list(OrderedDict.fromkeys(glyphList)) - return groups - - def readGroups(self, validate=None): - """ - Read groups.plist. Returns a dict. - ``validate`` will validate the read data, by default it is set to the - class's validate value, can be overridden. - """ - if validate is None: - validate = self._validate - # handle up conversion - if self._formatVersion < UFOFormatVersion.FORMAT_3_0: - self._upConvertKerning(validate) - groups = self._upConvertedKerningData["groups"] - # normal - else: - groups = self._readGroups() - if validate: - valid, message = groupsValidator(groups) - if not valid: - raise UFOLibError(message) - return groups - - def getKerningGroupConversionRenameMaps(self, validate=None): - """ - Get maps defining the renaming that was done during any - needed kerning group conversion. This method returns a - dictionary of this form:: - - { - "side1" : {"old group name" : "new group name"}, - "side2" : {"old group name" : "new group name"} - } - - When no conversion has been performed, the side1 and side2 - dictionaries will be empty. - - ``validate`` will validate the groups, by default it is set to the - class's validate value, can be overridden. - """ - if validate is None: - validate = self._validate - if self._formatVersion >= UFOFormatVersion.FORMAT_3_0: - return dict(side1={}, side2={}) - # use the public group reader to force the load and - # conversion of the data if it hasn't happened yet. - self.readGroups(validate=validate) - return self._upConvertedKerningData["groupRenameMaps"] - - # fontinfo.plist - - def _readInfo(self, validate): - data = self._getPlist(FONTINFO_FILENAME, {}) - if validate and not isinstance(data, dict): - raise UFOLibError("fontinfo.plist is not properly formatted.") - return data - - def readInfo(self, info, validate=None): - """ - Read fontinfo.plist. It requires an object that allows - setting attributes with names that follow the fontinfo.plist - version 3 specification. This will write the attributes - defined in the file into the object. - - ``validate`` will validate the read data, by default it is set to the - class's validate value, can be overridden. - """ - if validate is None: - validate = self._validate - infoDict = self._readInfo(validate) - infoDataToSet = {} - # version 1 - if self._formatVersion == UFOFormatVersion.FORMAT_1_0: - for attr in fontInfoAttributesVersion1: - value = infoDict.get(attr) - if value is not None: - infoDataToSet[attr] = value - infoDataToSet = _convertFontInfoDataVersion1ToVersion2(infoDataToSet) - infoDataToSet = _convertFontInfoDataVersion2ToVersion3(infoDataToSet) - # version 2 - elif self._formatVersion == UFOFormatVersion.FORMAT_2_0: - for attr, dataValidationDict in list(fontInfoAttributesVersion2ValueData.items()): - value = infoDict.get(attr) - if value is None: - continue - infoDataToSet[attr] = value - infoDataToSet = _convertFontInfoDataVersion2ToVersion3(infoDataToSet) - # version 3.x - elif self._formatVersion.major == UFOFormatVersion.FORMAT_3_0.major: - for attr, dataValidationDict in list(fontInfoAttributesVersion3ValueData.items()): - value = infoDict.get(attr) - if value is None: - continue - infoDataToSet[attr] = value - # unsupported version - else: - raise NotImplementedError(self._formatVersion) - # validate data - if validate: - infoDataToSet = validateInfoVersion3Data(infoDataToSet) - # populate the object - for attr, value in list(infoDataToSet.items()): - try: - setattr(info, attr, value) - except AttributeError: - raise UFOLibError("The supplied info object does not support setting a necessary attribute (%s)." % attr) - - # kerning.plist - - def _readKerning(self): - data = self._getPlist(KERNING_FILENAME, {}) - return data - - def readKerning(self, validate=None): - """ - Read kerning.plist. Returns a dict. - - ``validate`` will validate the kerning data, by default it is set to the - class's validate value, can be overridden. - """ - if validate is None: - validate = self._validate - # handle up conversion - if self._formatVersion < UFOFormatVersion.FORMAT_3_0: - self._upConvertKerning(validate) - kerningNested = self._upConvertedKerningData["kerning"] - # normal - else: - kerningNested = self._readKerning() - if validate: - valid, message = kerningValidator(kerningNested) - if not valid: - raise UFOLibError(message) - # flatten - kerning = {} - for left in kerningNested: - for right in kerningNested[left]: - value = kerningNested[left][right] - kerning[left, right] = value - return kerning - - # lib.plist - - def readLib(self, validate=None): - """ - Read lib.plist. Returns a dict. - - ``validate`` will validate the data, by default it is set to the - class's validate value, can be overridden. - """ - if validate is None: - validate = self._validate - data = self._getPlist(LIB_FILENAME, {}) - if validate: - valid, message = fontLibValidator(data) - if not valid: - raise UFOLibError(message) - return data - - # features.fea - - def readFeatures(self): - """ - Read features.fea. Return a string. - The returned string is empty if the file is missing. - """ - try: - with self.fs.open(FEATURES_FILENAME, "r", encoding="utf-8") as f: - return f.read() - except fs.errors.ResourceNotFound: - return "" - - # glyph sets & layers - - def _readLayerContents(self, validate): - """ - Rebuild the layer contents list by checking what glyphsets - are available on disk. - - ``validate`` will validate the layer contents. - """ - if self._formatVersion < UFOFormatVersion.FORMAT_3_0: - return [(DEFAULT_LAYER_NAME, DEFAULT_GLYPHS_DIRNAME)] - contents = self._getPlist(LAYERCONTENTS_FILENAME) - if validate: - valid, error = layerContentsValidator(contents, self.fs) - if not valid: - raise UFOLibError(error) - return contents - - def getLayerNames(self, validate=None): - """ - Get the ordered layer names from layercontents.plist. - - ``validate`` will validate the data, by default it is set to the - class's validate value, can be overridden. - """ - if validate is None: - validate = self._validate - layerContents = self._readLayerContents(validate) - layerNames = [layerName for layerName, directoryName in layerContents] - return layerNames - - def getDefaultLayerName(self, validate=None): - """ - Get the default layer name from layercontents.plist. - - ``validate`` will validate the data, by default it is set to the - class's validate value, can be overridden. - """ - if validate is None: - validate = self._validate - layerContents = self._readLayerContents(validate) - for layerName, layerDirectory in layerContents: - if layerDirectory == DEFAULT_GLYPHS_DIRNAME: - return layerName - # this will already have been raised during __init__ - raise UFOLibError("The default layer is not defined in layercontents.plist.") - - def getGlyphSet(self, layerName=None, validateRead=None, validateWrite=None): - """ - Return the GlyphSet associated with the - glyphs directory mapped to layerName - in the UFO. If layerName is not provided, - the name retrieved with getDefaultLayerName - will be used. - - ``validateRead`` will validate the read data, by default it is set to the - class's validate value, can be overridden. - ``validateWrite`` will validate the written data, by default it is set to the - class's validate value, can be overridden. - """ - from fontTools.ufoLib.glifLib import GlyphSet - - if validateRead is None: - validateRead = self._validate - if validateWrite is None: - validateWrite = self._validate - if layerName is None: - layerName = self.getDefaultLayerName(validate=validateRead) - directory = None - layerContents = self._readLayerContents(validateRead) - for storedLayerName, storedLayerDirectory in layerContents: - if layerName == storedLayerName: - directory = storedLayerDirectory - break - if directory is None: - raise UFOLibError("No glyphs directory is mapped to \"%s\"." % layerName) - try: - glyphSubFS = self.fs.opendir(directory) - except fs.errors.ResourceNotFound: - raise UFOLibError( - f"No '{directory}' directory for layer '{layerName}'" - ) - return GlyphSet( - glyphSubFS, - ufoFormatVersion=self._formatVersion, - validateRead=validateRead, - validateWrite=validateWrite, - expectContentsFile=True - ) - - def getCharacterMapping(self, layerName=None, validate=None): - """ - Return a dictionary that maps unicode values (ints) to - lists of glyph names. - """ - if validate is None: - validate = self._validate - glyphSet = self.getGlyphSet(layerName, validateRead=validate, validateWrite=True) - allUnicodes = glyphSet.getUnicodes() - cmap = {} - for glyphName, unicodes in allUnicodes.items(): - for code in unicodes: - if code in cmap: - cmap[code].append(glyphName) - else: - cmap[code] = [glyphName] - return cmap - - # /data - - def getDataDirectoryListing(self): - """ - Returns a list of all files in the data directory. - The returned paths will be relative to the UFO. - This will not list directory names, only file names. - Thus, empty directories will be skipped. - """ - try: - self._dataFS = self.fs.opendir(DATA_DIRNAME) - except fs.errors.ResourceNotFound: - return [] - except fs.errors.DirectoryExpected: - raise UFOLibError("The UFO contains a \"data\" file instead of a directory.") - try: - # fs Walker.files method returns "absolute" paths (in terms of the - # root of the 'data' SubFS), so we strip the leading '/' to make - # them relative - return [ - p.lstrip("/") for p in self._dataFS.walk.files() - ] - except fs.errors.ResourceError: - return [] - - def getImageDirectoryListing(self, validate=None): - """ - Returns a list of all image file names in - the images directory. Each of the images will - have been verified to have the PNG signature. - - ``validate`` will validate the data, by default it is set to the - class's validate value, can be overridden. - """ - if self._formatVersion < UFOFormatVersion.FORMAT_3_0: - return [] - if validate is None: - validate = self._validate - try: - self._imagesFS = imagesFS = self.fs.opendir(IMAGES_DIRNAME) - except fs.errors.ResourceNotFound: - return [] - except fs.errors.DirectoryExpected: - raise UFOLibError("The UFO contains an \"images\" file instead of a directory.") - result = [] - for path in imagesFS.scandir("/"): - if path.is_dir: - # silently skip this as version control - # systems often have hidden directories - continue - if validate: - with imagesFS.openbin(path.name) as fp: - valid, error = pngValidator(fileObj=fp) - if valid: - result.append(path.name) - else: - result.append(path.name) - return result - - def readData(self, fileName): - """ - Return bytes for the file named 'fileName' inside the 'data/' directory. - """ - fileName = fsdecode(fileName) - try: - try: - dataFS = self._dataFS - except AttributeError: - # in case readData is called before getDataDirectoryListing - dataFS = self.fs.opendir(DATA_DIRNAME) - data = dataFS.readbytes(fileName) - except fs.errors.ResourceNotFound: - raise UFOLibError(f"No data file named '{fileName}' on {self.fs}") - return data - - def readImage(self, fileName, validate=None): - """ - Return image data for the file named fileName. - - ``validate`` will validate the data, by default it is set to the - class's validate value, can be overridden. - """ - if validate is None: - validate = self._validate - if self._formatVersion < UFOFormatVersion.FORMAT_3_0: - raise UFOLibError( - f"Reading images is not allowed in UFO {self._formatVersion.major}." - ) - fileName = fsdecode(fileName) - try: - try: - imagesFS = self._imagesFS - except AttributeError: - # in case readImage is called before getImageDirectoryListing - imagesFS = self.fs.opendir(IMAGES_DIRNAME) - data = imagesFS.readbytes(fileName) - except fs.errors.ResourceNotFound: - raise UFOLibError(f"No image file named '{fileName}' on {self.fs}") - if validate: - valid, error = pngValidator(data=data) - if not valid: - raise UFOLibError(error) - return data - - def close(self): - if self._shouldClose: - self.fs.close() - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, exc_tb): - self.close() + """ + Read the various components of the .ufo. + + By default read data is validated. Set ``validate`` to + ``False`` to not validate the data. + """ + + def __init__(self, path, validate=True): + if hasattr(path, "__fspath__"): # support os.PathLike objects + path = path.__fspath__() + + if isinstance(path, str): + structure = _sniffFileStructure(path) + try: + if structure is UFOFileStructure.ZIP: + parentFS = fs.zipfs.ZipFS(path, write=False, encoding="utf-8") + else: + parentFS = fs.osfs.OSFS(path) + except fs.errors.CreateFailed as e: + raise UFOLibError(f"unable to open '{path}': {e}") + + if structure is UFOFileStructure.ZIP: + # .ufoz zip files must contain a single root directory, with arbitrary + # name, containing all the UFO files + rootDirs = [ + p.name + for p in parentFS.scandir("/") + # exclude macOS metadata contained in zip file + if p.is_dir and p.name != "__MACOSX" + ] + if len(rootDirs) == 1: + # 'ClosingSubFS' ensures that the parent zip file is closed when + # its root subdirectory is closed + self.fs = parentFS.opendir( + rootDirs[0], factory=fs.subfs.ClosingSubFS + ) + else: + raise UFOLibError( + "Expected exactly 1 root directory, found %d" % len(rootDirs) + ) + else: + # normal UFO 'packages' are just a single folder + self.fs = parentFS + # when passed a path string, we make sure we close the newly opened fs + # upon calling UFOReader.close method or context manager's __exit__ + self._shouldClose = True + self._fileStructure = structure + elif isinstance(path, fs.base.FS): + filesystem = path + try: + filesystem.check() + except fs.errors.FilesystemClosed: + raise UFOLibError("the filesystem '%s' is closed" % path) + else: + self.fs = filesystem + try: + path = filesystem.getsyspath("/") + except fs.errors.NoSysPath: + # network or in-memory FS may not map to the local one + path = str(filesystem) + # when user passed an already initialized fs instance, it is her + # responsibility to close it, thus UFOReader.close/__exit__ are no-op + self._shouldClose = False + # default to a 'package' structure + self._fileStructure = UFOFileStructure.PACKAGE + else: + raise TypeError( + "Expected a path string or fs.base.FS object, found '%s'" + % type(path).__name__ + ) + self._path = fsdecode(path) + self._validate = validate + self._upConvertedKerningData = None + + try: + self.readMetaInfo(validate=validate) + except UFOLibError: + self.close() + raise + + # properties + + def _get_path(self): + import warnings + + warnings.warn( + "The 'path' attribute is deprecated; use the 'fs' attribute instead", + DeprecationWarning, + stacklevel=2, + ) + return self._path + + path = property(_get_path, doc="The path of the UFO (DEPRECATED).") + + def _get_formatVersion(self): + import warnings + + warnings.warn( + "The 'formatVersion' attribute is deprecated; use the 'formatVersionTuple'", + DeprecationWarning, + stacklevel=2, + ) + return self._formatVersion.major + + formatVersion = property( + _get_formatVersion, + doc="The (major) format version of the UFO. DEPRECATED: Use formatVersionTuple", + ) + + @property + def formatVersionTuple(self): + """The (major, minor) format version of the UFO. + This is determined by reading metainfo.plist during __init__. + """ + return self._formatVersion + + def _get_fileStructure(self): + return self._fileStructure + + fileStructure = property( + _get_fileStructure, + doc=( + "The file structure of the UFO: " + "either UFOFileStructure.ZIP or UFOFileStructure.PACKAGE" + ), + ) + + # up conversion + + def _upConvertKerning(self, validate): + """ + Up convert kerning and groups in UFO 1 and 2. + The data will be held internally until each bit of data + has been retrieved. The conversion of both must be done + at once, so the raw data is cached and an error is raised + if one bit of data becomes obsolete before it is called. + + ``validate`` will validate the data. + """ + if self._upConvertedKerningData: + testKerning = self._readKerning() + if testKerning != self._upConvertedKerningData["originalKerning"]: + raise UFOLibError( + "The data in kerning.plist has been modified since it was converted to UFO 3 format." + ) + testGroups = self._readGroups() + if testGroups != self._upConvertedKerningData["originalGroups"]: + raise UFOLibError( + "The data in groups.plist has been modified since it was converted to UFO 3 format." + ) + else: + groups = self._readGroups() + if validate: + invalidFormatMessage = "groups.plist is not properly formatted." + if not isinstance(groups, dict): + raise UFOLibError(invalidFormatMessage) + for groupName, glyphList in groups.items(): + if not isinstance(groupName, str): + raise UFOLibError(invalidFormatMessage) + elif not isinstance(glyphList, list): + raise UFOLibError(invalidFormatMessage) + for glyphName in glyphList: + if not isinstance(glyphName, str): + raise UFOLibError(invalidFormatMessage) + self._upConvertedKerningData = dict( + kerning={}, + originalKerning=self._readKerning(), + groups={}, + originalGroups=groups, + ) + # convert kerning and groups + kerning, groups, conversionMaps = convertUFO1OrUFO2KerningToUFO3Kerning( + self._upConvertedKerningData["originalKerning"], + deepcopy(self._upConvertedKerningData["originalGroups"]), + self.getGlyphSet(), + ) + # store + self._upConvertedKerningData["kerning"] = kerning + self._upConvertedKerningData["groups"] = groups + self._upConvertedKerningData["groupRenameMaps"] = conversionMaps + + # support methods + + def readBytesFromPath(self, path): + """ + Returns the bytes in the file at the given path. + The path must be relative to the UFO's filesystem root. + Returns None if the file does not exist. + """ + try: + return self.fs.readbytes(fsdecode(path)) + except fs.errors.ResourceNotFound: + return None + + def getReadFileForPath(self, path, encoding=None): + """ + Returns a file (or file-like) object for the file at the given path. + The path must be relative to the UFO path. + Returns None if the file does not exist. + By default the file is opened in binary mode (reads bytes). + If encoding is passed, the file is opened in text mode (reads str). + + Note: The caller is responsible for closing the open file. + """ + path = fsdecode(path) + try: + if encoding is None: + return self.fs.openbin(path) + else: + return self.fs.open(path, mode="r", encoding=encoding) + except fs.errors.ResourceNotFound: + return None + + # metainfo.plist + + def _readMetaInfo(self, validate=None): + """ + Read metainfo.plist and return raw data. Only used for internal operations. + + ``validate`` will validate the read data, by default it is set + to the class's validate value, can be overridden. + """ + if validate is None: + validate = self._validate + data = self._getPlist(METAINFO_FILENAME) + if validate and not isinstance(data, dict): + raise UFOLibError("metainfo.plist is not properly formatted.") + try: + formatVersionMajor = data["formatVersion"] + except KeyError: + raise UFOLibError( + f"Missing required formatVersion in '{METAINFO_FILENAME}' on {self.fs}" + ) + formatVersionMinor = data.setdefault("formatVersionMinor", 0) + + try: + formatVersion = UFOFormatVersion((formatVersionMajor, formatVersionMinor)) + except ValueError as e: + unsupportedMsg = ( + f"Unsupported UFO format ({formatVersionMajor}.{formatVersionMinor}) " + f"in '{METAINFO_FILENAME}' on {self.fs}" + ) + if validate: + from fontTools.ufoLib.errors import UnsupportedUFOFormat + + raise UnsupportedUFOFormat(unsupportedMsg) from e + + formatVersion = UFOFormatVersion.default() + logger.warning( + "%s. Assuming the latest supported version (%s). " + "Some data may be skipped or parsed incorrectly", + unsupportedMsg, + formatVersion, + ) + data["formatVersionTuple"] = formatVersion + return data + + def readMetaInfo(self, validate=None): + """ + Read metainfo.plist and set formatVersion. Only used for internal operations. + + ``validate`` will validate the read data, by default it is set + to the class's validate value, can be overridden. + """ + data = self._readMetaInfo(validate=validate) + self._formatVersion = data["formatVersionTuple"] + + # groups.plist + + def _readGroups(self): + groups = self._getPlist(GROUPS_FILENAME, {}) + # remove any duplicate glyphs in a kerning group + for groupName, glyphList in groups.items(): + if groupName.startswith(("public.kern1.", "public.kern2.")): + groups[groupName] = list(OrderedDict.fromkeys(glyphList)) + return groups + + def readGroups(self, validate=None): + """ + Read groups.plist. Returns a dict. + ``validate`` will validate the read data, by default it is set to the + class's validate value, can be overridden. + """ + if validate is None: + validate = self._validate + # handle up conversion + if self._formatVersion < UFOFormatVersion.FORMAT_3_0: + self._upConvertKerning(validate) + groups = self._upConvertedKerningData["groups"] + # normal + else: + groups = self._readGroups() + if validate: + valid, message = groupsValidator(groups) + if not valid: + raise UFOLibError(message) + return groups + + def getKerningGroupConversionRenameMaps(self, validate=None): + """ + Get maps defining the renaming that was done during any + needed kerning group conversion. This method returns a + dictionary of this form:: + + { + "side1" : {"old group name" : "new group name"}, + "side2" : {"old group name" : "new group name"} + } + + When no conversion has been performed, the side1 and side2 + dictionaries will be empty. + + ``validate`` will validate the groups, by default it is set to the + class's validate value, can be overridden. + """ + if validate is None: + validate = self._validate + if self._formatVersion >= UFOFormatVersion.FORMAT_3_0: + return dict(side1={}, side2={}) + # use the public group reader to force the load and + # conversion of the data if it hasn't happened yet. + self.readGroups(validate=validate) + return self._upConvertedKerningData["groupRenameMaps"] + + # fontinfo.plist + + def _readInfo(self, validate): + data = self._getPlist(FONTINFO_FILENAME, {}) + if validate and not isinstance(data, dict): + raise UFOLibError("fontinfo.plist is not properly formatted.") + return data + + def readInfo(self, info, validate=None): + """ + Read fontinfo.plist. It requires an object that allows + setting attributes with names that follow the fontinfo.plist + version 3 specification. This will write the attributes + defined in the file into the object. + + ``validate`` will validate the read data, by default it is set to the + class's validate value, can be overridden. + """ + if validate is None: + validate = self._validate + infoDict = self._readInfo(validate) + infoDataToSet = {} + # version 1 + if self._formatVersion == UFOFormatVersion.FORMAT_1_0: + for attr in fontInfoAttributesVersion1: + value = infoDict.get(attr) + if value is not None: + infoDataToSet[attr] = value + infoDataToSet = _convertFontInfoDataVersion1ToVersion2(infoDataToSet) + infoDataToSet = _convertFontInfoDataVersion2ToVersion3(infoDataToSet) + # version 2 + elif self._formatVersion == UFOFormatVersion.FORMAT_2_0: + for attr, dataValidationDict in list( + fontInfoAttributesVersion2ValueData.items() + ): + value = infoDict.get(attr) + if value is None: + continue + infoDataToSet[attr] = value + infoDataToSet = _convertFontInfoDataVersion2ToVersion3(infoDataToSet) + # version 3.x + elif self._formatVersion.major == UFOFormatVersion.FORMAT_3_0.major: + for attr, dataValidationDict in list( + fontInfoAttributesVersion3ValueData.items() + ): + value = infoDict.get(attr) + if value is None: + continue + infoDataToSet[attr] = value + # unsupported version + else: + raise NotImplementedError(self._formatVersion) + # validate data + if validate: + infoDataToSet = validateInfoVersion3Data(infoDataToSet) + # populate the object + for attr, value in list(infoDataToSet.items()): + try: + setattr(info, attr, value) + except AttributeError: + raise UFOLibError( + "The supplied info object does not support setting a necessary attribute (%s)." + % attr + ) + + # kerning.plist + + def _readKerning(self): + data = self._getPlist(KERNING_FILENAME, {}) + return data + + def readKerning(self, validate=None): + """ + Read kerning.plist. Returns a dict. + + ``validate`` will validate the kerning data, by default it is set to the + class's validate value, can be overridden. + """ + if validate is None: + validate = self._validate + # handle up conversion + if self._formatVersion < UFOFormatVersion.FORMAT_3_0: + self._upConvertKerning(validate) + kerningNested = self._upConvertedKerningData["kerning"] + # normal + else: + kerningNested = self._readKerning() + if validate: + valid, message = kerningValidator(kerningNested) + if not valid: + raise UFOLibError(message) + # flatten + kerning = {} + for left in kerningNested: + for right in kerningNested[left]: + value = kerningNested[left][right] + kerning[left, right] = value + return kerning + + # lib.plist + + def readLib(self, validate=None): + """ + Read lib.plist. Returns a dict. + + ``validate`` will validate the data, by default it is set to the + class's validate value, can be overridden. + """ + if validate is None: + validate = self._validate + data = self._getPlist(LIB_FILENAME, {}) + if validate: + valid, message = fontLibValidator(data) + if not valid: + raise UFOLibError(message) + return data + + # features.fea + + def readFeatures(self): + """ + Read features.fea. Return a string. + The returned string is empty if the file is missing. + """ + try: + with self.fs.open(FEATURES_FILENAME, "r", encoding="utf-8") as f: + return f.read() + except fs.errors.ResourceNotFound: + return "" + + # glyph sets & layers + + def _readLayerContents(self, validate): + """ + Rebuild the layer contents list by checking what glyphsets + are available on disk. + + ``validate`` will validate the layer contents. + """ + if self._formatVersion < UFOFormatVersion.FORMAT_3_0: + return [(DEFAULT_LAYER_NAME, DEFAULT_GLYPHS_DIRNAME)] + contents = self._getPlist(LAYERCONTENTS_FILENAME) + if validate: + valid, error = layerContentsValidator(contents, self.fs) + if not valid: + raise UFOLibError(error) + return contents + + def getLayerNames(self, validate=None): + """ + Get the ordered layer names from layercontents.plist. + + ``validate`` will validate the data, by default it is set to the + class's validate value, can be overridden. + """ + if validate is None: + validate = self._validate + layerContents = self._readLayerContents(validate) + layerNames = [layerName for layerName, directoryName in layerContents] + return layerNames + + def getDefaultLayerName(self, validate=None): + """ + Get the default layer name from layercontents.plist. + + ``validate`` will validate the data, by default it is set to the + class's validate value, can be overridden. + """ + if validate is None: + validate = self._validate + layerContents = self._readLayerContents(validate) + for layerName, layerDirectory in layerContents: + if layerDirectory == DEFAULT_GLYPHS_DIRNAME: + return layerName + # this will already have been raised during __init__ + raise UFOLibError("The default layer is not defined in layercontents.plist.") + + def getGlyphSet(self, layerName=None, validateRead=None, validateWrite=None): + """ + Return the GlyphSet associated with the + glyphs directory mapped to layerName + in the UFO. If layerName is not provided, + the name retrieved with getDefaultLayerName + will be used. + + ``validateRead`` will validate the read data, by default it is set to the + class's validate value, can be overridden. + ``validateWrite`` will validate the written data, by default it is set to the + class's validate value, can be overridden. + """ + from fontTools.ufoLib.glifLib import GlyphSet + + if validateRead is None: + validateRead = self._validate + if validateWrite is None: + validateWrite = self._validate + if layerName is None: + layerName = self.getDefaultLayerName(validate=validateRead) + directory = None + layerContents = self._readLayerContents(validateRead) + for storedLayerName, storedLayerDirectory in layerContents: + if layerName == storedLayerName: + directory = storedLayerDirectory + break + if directory is None: + raise UFOLibError('No glyphs directory is mapped to "%s".' % layerName) + try: + glyphSubFS = self.fs.opendir(directory) + except fs.errors.ResourceNotFound: + raise UFOLibError(f"No '{directory}' directory for layer '{layerName}'") + return GlyphSet( + glyphSubFS, + ufoFormatVersion=self._formatVersion, + validateRead=validateRead, + validateWrite=validateWrite, + expectContentsFile=True, + ) + + def getCharacterMapping(self, layerName=None, validate=None): + """ + Return a dictionary that maps unicode values (ints) to + lists of glyph names. + """ + if validate is None: + validate = self._validate + glyphSet = self.getGlyphSet( + layerName, validateRead=validate, validateWrite=True + ) + allUnicodes = glyphSet.getUnicodes() + cmap = {} + for glyphName, unicodes in allUnicodes.items(): + for code in unicodes: + if code in cmap: + cmap[code].append(glyphName) + else: + cmap[code] = [glyphName] + return cmap + + # /data + + def getDataDirectoryListing(self): + """ + Returns a list of all files in the data directory. + The returned paths will be relative to the UFO. + This will not list directory names, only file names. + Thus, empty directories will be skipped. + """ + try: + self._dataFS = self.fs.opendir(DATA_DIRNAME) + except fs.errors.ResourceNotFound: + return [] + except fs.errors.DirectoryExpected: + raise UFOLibError('The UFO contains a "data" file instead of a directory.') + try: + # fs Walker.files method returns "absolute" paths (in terms of the + # root of the 'data' SubFS), so we strip the leading '/' to make + # them relative + return [p.lstrip("/") for p in self._dataFS.walk.files()] + except fs.errors.ResourceError: + return [] + + def getImageDirectoryListing(self, validate=None): + """ + Returns a list of all image file names in + the images directory. Each of the images will + have been verified to have the PNG signature. + + ``validate`` will validate the data, by default it is set to the + class's validate value, can be overridden. + """ + if self._formatVersion < UFOFormatVersion.FORMAT_3_0: + return [] + if validate is None: + validate = self._validate + try: + self._imagesFS = imagesFS = self.fs.opendir(IMAGES_DIRNAME) + except fs.errors.ResourceNotFound: + return [] + except fs.errors.DirectoryExpected: + raise UFOLibError( + 'The UFO contains an "images" file instead of a directory.' + ) + result = [] + for path in imagesFS.scandir("/"): + if path.is_dir: + # silently skip this as version control + # systems often have hidden directories + continue + if validate: + with imagesFS.openbin(path.name) as fp: + valid, error = pngValidator(fileObj=fp) + if valid: + result.append(path.name) + else: + result.append(path.name) + return result + + def readData(self, fileName): + """ + Return bytes for the file named 'fileName' inside the 'data/' directory. + """ + fileName = fsdecode(fileName) + try: + try: + dataFS = self._dataFS + except AttributeError: + # in case readData is called before getDataDirectoryListing + dataFS = self.fs.opendir(DATA_DIRNAME) + data = dataFS.readbytes(fileName) + except fs.errors.ResourceNotFound: + raise UFOLibError(f"No data file named '{fileName}' on {self.fs}") + return data + + def readImage(self, fileName, validate=None): + """ + Return image data for the file named fileName. + + ``validate`` will validate the data, by default it is set to the + class's validate value, can be overridden. + """ + if validate is None: + validate = self._validate + if self._formatVersion < UFOFormatVersion.FORMAT_3_0: + raise UFOLibError( + f"Reading images is not allowed in UFO {self._formatVersion.major}." + ) + fileName = fsdecode(fileName) + try: + try: + imagesFS = self._imagesFS + except AttributeError: + # in case readImage is called before getImageDirectoryListing + imagesFS = self.fs.opendir(IMAGES_DIRNAME) + data = imagesFS.readbytes(fileName) + except fs.errors.ResourceNotFound: + raise UFOLibError(f"No image file named '{fileName}' on {self.fs}") + if validate: + valid, error = pngValidator(data=data) + if not valid: + raise UFOLibError(error) + return data + + def close(self): + if self._shouldClose: + self.fs.close() + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, exc_tb): + self.close() # ---------- # UFO Writer # ---------- + class UFOWriter(UFOReader): - """ - Write the various components of the .ufo. - - By default, the written data will be validated before writing. Set ``validate`` to - ``False`` if you do not want to validate the data. Validation can also be overriden - on a per method level if desired. - - The ``formatVersion`` argument allows to specify the UFO format version as a tuple - of integers (major, minor), or as a single integer for the major digit only (minor - is implied as 0). By default the latest formatVersion will be used; currently it's - 3.0, which is equivalent to formatVersion=(3, 0). - - An UnsupportedUFOFormat exception is raised if the requested UFO formatVersion is - not supported. - """ - - def __init__( - self, - path, - formatVersion=None, - fileCreator="com.github.fonttools.ufoLib", - structure=None, - validate=True, - ): - try: - formatVersion = UFOFormatVersion(formatVersion) - except ValueError as e: - from fontTools.ufoLib.errors import UnsupportedUFOFormat - - raise UnsupportedUFOFormat(f"Unsupported UFO format: {formatVersion!r}") from e - - if hasattr(path, "__fspath__"): # support os.PathLike objects - path = path.__fspath__() - - if isinstance(path, str): - # normalize path by removing trailing or double slashes - path = os.path.normpath(path) - havePreviousFile = os.path.exists(path) - if havePreviousFile: - # ensure we use the same structure as the destination - existingStructure = _sniffFileStructure(path) - if structure is not None: - try: - structure = UFOFileStructure(structure) - except ValueError: - raise UFOLibError( - "Invalid or unsupported structure: '%s'" % structure - ) - if structure is not existingStructure: - raise UFOLibError( - "A UFO with a different structure (%s) already exists " - "at the given path: '%s'" % (existingStructure, path) - ) - else: - structure = existingStructure - else: - # if not exists, default to 'package' structure - if structure is None: - structure = UFOFileStructure.PACKAGE - dirName = os.path.dirname(path) - if dirName and not os.path.isdir(dirName): - raise UFOLibError( - "Cannot write to '%s': directory does not exist" % path - ) - if structure is UFOFileStructure.ZIP: - if havePreviousFile: - # we can't write a zip in-place, so we have to copy its - # contents to a temporary location and work from there, then - # upon closing UFOWriter we create the final zip file - parentFS = fs.tempfs.TempFS() - with fs.zipfs.ZipFS(path, encoding="utf-8") as origFS: - fs.copy.copy_fs(origFS, parentFS) - # if output path is an existing zip, we require that it contains - # one, and only one, root directory (with arbitrary name), in turn - # containing all the existing UFO contents - rootDirs = [ - p.name for p in parentFS.scandir("/") - # exclude macOS metadata contained in zip file - if p.is_dir and p.name != "__MACOSX" - ] - if len(rootDirs) != 1: - raise UFOLibError( - "Expected exactly 1 root directory, found %d" % len(rootDirs) - ) - else: - # 'ClosingSubFS' ensures that the parent filesystem is closed - # when its root subdirectory is closed - self.fs = parentFS.opendir( - rootDirs[0], factory=fs.subfs.ClosingSubFS - ) - else: - # if the output zip file didn't exist, we create the root folder; - # we name it the same as input 'path', but with '.ufo' extension - rootDir = os.path.splitext(os.path.basename(path))[0] + ".ufo" - parentFS = fs.zipfs.ZipFS(path, write=True, encoding="utf-8") - parentFS.makedir(rootDir) - self.fs = parentFS.opendir(rootDir, factory=fs.subfs.ClosingSubFS) - else: - self.fs = fs.osfs.OSFS(path, create=True) - self._fileStructure = structure - self._havePreviousFile = havePreviousFile - self._shouldClose = True - elif isinstance(path, fs.base.FS): - filesystem = path - try: - filesystem.check() - except fs.errors.FilesystemClosed: - raise UFOLibError("the filesystem '%s' is closed" % path) - else: - self.fs = filesystem - try: - path = filesystem.getsyspath("/") - except fs.errors.NoSysPath: - # network or in-memory FS may not map to the local one - path = str(filesystem) - # if passed an FS object, always use 'package' structure - if structure and structure is not UFOFileStructure.PACKAGE: - import warnings - - warnings.warn( - "The 'structure' argument is not used when input is an FS object", - UserWarning, - stacklevel=2, - ) - self._fileStructure = UFOFileStructure.PACKAGE - # if FS contains a "metainfo.plist", we consider it non-empty - self._havePreviousFile = filesystem.exists(METAINFO_FILENAME) - # the user is responsible for closing the FS object - self._shouldClose = False - else: - raise TypeError( - "Expected a path string or fs object, found %s" - % type(path).__name__ - ) - - # establish some basic stuff - self._path = fsdecode(path) - self._formatVersion = formatVersion - self._fileCreator = fileCreator - self._downConversionKerningData = None - self._validate = validate - # if the file already exists, get the format version. - # this will be needed for up and down conversion. - previousFormatVersion = None - if self._havePreviousFile: - metaInfo = self._readMetaInfo(validate=validate) - previousFormatVersion = metaInfo["formatVersionTuple"] - # catch down conversion - if previousFormatVersion > formatVersion: - from fontTools.ufoLib.errors import UnsupportedUFOFormat - - raise UnsupportedUFOFormat( - "The UFO located at this path is a higher version " - f"({previousFormatVersion}) than the version ({formatVersion}) " - "that is trying to be written. This is not supported." - ) - # handle the layer contents - self.layerContents = {} - if previousFormatVersion is not None and previousFormatVersion.major >= 3: - # already exists - self.layerContents = OrderedDict(self._readLayerContents(validate)) - else: - # previous < 3 - # imply the layer contents - if self.fs.exists(DEFAULT_GLYPHS_DIRNAME): - self.layerContents = {DEFAULT_LAYER_NAME : DEFAULT_GLYPHS_DIRNAME} - # write the new metainfo - self._writeMetaInfo() - - # properties - - def _get_fileCreator(self): - return self._fileCreator - - fileCreator = property(_get_fileCreator, doc="The file creator of the UFO. This is set into metainfo.plist during __init__.") - - # support methods for file system interaction - - def copyFromReader(self, reader, sourcePath, destPath): - """ - Copy the sourcePath in the provided UFOReader to destPath - in this writer. The paths must be relative. This works with - both individual files and directories. - """ - if not isinstance(reader, UFOReader): - raise UFOLibError("The reader must be an instance of UFOReader.") - sourcePath = fsdecode(sourcePath) - destPath = fsdecode(destPath) - if not reader.fs.exists(sourcePath): - raise UFOLibError("The reader does not have data located at \"%s\"." % sourcePath) - if self.fs.exists(destPath): - raise UFOLibError("A file named \"%s\" already exists." % destPath) - # create the destination directory if it doesn't exist - self.fs.makedirs(fs.path.dirname(destPath), recreate=True) - if reader.fs.isdir(sourcePath): - fs.copy.copy_dir(reader.fs, sourcePath, self.fs, destPath) - else: - fs.copy.copy_file(reader.fs, sourcePath, self.fs, destPath) - - def writeBytesToPath(self, path, data): - """ - Write bytes to a path relative to the UFO filesystem's root. - If writing to an existing UFO, check to see if data matches the data - that is already in the file at path; if so, the file is not rewritten - so that the modification date is preserved. - If needed, the directory tree for the given path will be built. - """ - path = fsdecode(path) - if self._havePreviousFile: - if self.fs.isfile(path) and data == self.fs.readbytes(path): - return - try: - self.fs.writebytes(path, data) - except fs.errors.FileExpected: - raise UFOLibError("A directory exists at '%s'" % path) - except fs.errors.ResourceNotFound: - self.fs.makedirs(fs.path.dirname(path), recreate=True) - self.fs.writebytes(path, data) - - def getFileObjectForPath(self, path, mode="w", encoding=None): - """ - Returns a file (or file-like) object for the - file at the given path. The path must be relative - to the UFO path. Returns None if the file does - not exist and the mode is "r" or "rb. - An encoding may be passed if the file is opened in text mode. - - Note: The caller is responsible for closing the open file. - """ - path = fsdecode(path) - try: - return self.fs.open(path, mode=mode, encoding=encoding) - except fs.errors.ResourceNotFound as e: - m = mode[0] - if m == "r": - # XXX I think we should just let it raise. The docstring, - # however, says that this returns None if mode is 'r' - return None - elif m == "w" or m == "a" or m == "x": - self.fs.makedirs(fs.path.dirname(path), recreate=True) - return self.fs.open(path, mode=mode, encoding=encoding) - except fs.errors.ResourceError as e: - return UFOLibError( - f"unable to open '{path}' on {self.fs}: {e}" - ) - - def removePath(self, path, force=False, removeEmptyParents=True): - """ - Remove the file (or directory) at path. The path - must be relative to the UFO. - Raises UFOLibError if the path doesn't exist. - If force=True, ignore non-existent paths. - If the directory where 'path' is located becomes empty, it will - be automatically removed, unless 'removeEmptyParents' is False. - """ - path = fsdecode(path) - try: - self.fs.remove(path) - except fs.errors.FileExpected: - self.fs.removetree(path) - except fs.errors.ResourceNotFound: - if not force: - raise UFOLibError( - f"'{path}' does not exist on {self.fs}" - ) - if removeEmptyParents: - parent = fs.path.dirname(path) - if parent: - fs.tools.remove_empty(self.fs, parent) - - # alias kept for backward compatibility with old API - removeFileForPath = removePath - - # UFO mod time - - def setModificationTime(self): - """ - Set the UFO modification time to the current time. - This is never called automatically. It is up to the - caller to call this when finished working on the UFO. - """ - path = self._path - if path is not None and os.path.exists(path): - try: - # this may fail on some filesystems (e.g. SMB servers) - os.utime(path, None) - except OSError as e: - logger.warning("Failed to set modified time: %s", e) - - # metainfo.plist - - def _writeMetaInfo(self): - metaInfo = dict( - creator=self._fileCreator, - formatVersion=self._formatVersion.major, - ) - if self._formatVersion.minor != 0: - metaInfo["formatVersionMinor"] = self._formatVersion.minor - self._writePlist(METAINFO_FILENAME, metaInfo) - - # groups.plist - - def setKerningGroupConversionRenameMaps(self, maps): - """ - Set maps defining the renaming that should be done - when writing groups and kerning in UFO 1 and UFO 2. - This will effectively undo the conversion done when - UFOReader reads this data. The dictionary should have - this form:: - - { - "side1" : {"group name to use when writing" : "group name in data"}, - "side2" : {"group name to use when writing" : "group name in data"} - } - - This is the same form returned by UFOReader's - getKerningGroupConversionRenameMaps method. - """ - if self._formatVersion >= UFOFormatVersion.FORMAT_3_0: - return # XXX raise an error here - # flip the dictionaries - remap = {} - for side in ("side1", "side2"): - for writeName, dataName in list(maps[side].items()): - remap[dataName] = writeName - self._downConversionKerningData = dict(groupRenameMap=remap) - - def writeGroups(self, groups, validate=None): - """ - Write groups.plist. This method requires a - dict of glyph groups as an argument. - - ``validate`` will validate the data, by default it is set to the - class's validate value, can be overridden. - """ - if validate is None: - validate = self._validate - # validate the data structure - if validate: - valid, message = groupsValidator(groups) - if not valid: - raise UFOLibError(message) - # down convert - if ( - self._formatVersion < UFOFormatVersion.FORMAT_3_0 - and self._downConversionKerningData is not None - ): - remap = self._downConversionKerningData["groupRenameMap"] - remappedGroups = {} - # there are some edge cases here that are ignored: - # 1. if a group is being renamed to a name that - # already exists, the existing group is always - # overwritten. (this is why there are two loops - # below.) there doesn't seem to be a logical - # solution to groups mismatching and overwriting - # with the specifiecd group seems like a better - # solution than throwing an error. - # 2. if side 1 and side 2 groups are being renamed - # to the same group name there is no check to - # ensure that the contents are identical. that - # is left up to the caller. - for name, contents in list(groups.items()): - if name in remap: - continue - remappedGroups[name] = contents - for name, contents in list(groups.items()): - if name not in remap: - continue - name = remap[name] - remappedGroups[name] = contents - groups = remappedGroups - # pack and write - groupsNew = {} - for key, value in groups.items(): - groupsNew[key] = list(value) - if groupsNew: - self._writePlist(GROUPS_FILENAME, groupsNew) - elif self._havePreviousFile: - self.removePath(GROUPS_FILENAME, force=True, removeEmptyParents=False) - - # fontinfo.plist - - def writeInfo(self, info, validate=None): - """ - Write info.plist. This method requires an object - that supports getting attributes that follow the - fontinfo.plist version 2 specification. Attributes - will be taken from the given object and written - into the file. - - ``validate`` will validate the data, by default it is set to the - class's validate value, can be overridden. - """ - if validate is None: - validate = self._validate - # gather version 3 data - infoData = {} - for attr in list(fontInfoAttributesVersion3ValueData.keys()): - if hasattr(info, attr): - try: - value = getattr(info, attr) - except AttributeError: - raise UFOLibError("The supplied info object does not support getting a necessary attribute (%s)." % attr) - if value is None: - continue - infoData[attr] = value - # down convert data if necessary and validate - if self._formatVersion == UFOFormatVersion.FORMAT_3_0: - if validate: - infoData = validateInfoVersion3Data(infoData) - elif self._formatVersion == UFOFormatVersion.FORMAT_2_0: - infoData = _convertFontInfoDataVersion3ToVersion2(infoData) - if validate: - infoData = validateInfoVersion2Data(infoData) - elif self._formatVersion == UFOFormatVersion.FORMAT_1_0: - infoData = _convertFontInfoDataVersion3ToVersion2(infoData) - if validate: - infoData = validateInfoVersion2Data(infoData) - infoData = _convertFontInfoDataVersion2ToVersion1(infoData) - # write file if there is anything to write - if infoData: - self._writePlist(FONTINFO_FILENAME, infoData) - - # kerning.plist - - def writeKerning(self, kerning, validate=None): - """ - Write kerning.plist. This method requires a - dict of kerning pairs as an argument. - - This performs basic structural validation of the kerning, - but it does not check for compliance with the spec in - regards to conflicting pairs. The assumption is that the - kerning data being passed is standards compliant. - - ``validate`` will validate the data, by default it is set to the - class's validate value, can be overridden. - """ - if validate is None: - validate = self._validate - # validate the data structure - if validate: - invalidFormatMessage = "The kerning is not properly formatted." - if not isDictEnough(kerning): - raise UFOLibError(invalidFormatMessage) - for pair, value in list(kerning.items()): - if not isinstance(pair, (list, tuple)): - raise UFOLibError(invalidFormatMessage) - if not len(pair) == 2: - raise UFOLibError(invalidFormatMessage) - if not isinstance(pair[0], str): - raise UFOLibError(invalidFormatMessage) - if not isinstance(pair[1], str): - raise UFOLibError(invalidFormatMessage) - if not isinstance(value, numberTypes): - raise UFOLibError(invalidFormatMessage) - # down convert - if ( - self._formatVersion < UFOFormatVersion.FORMAT_3_0 - and self._downConversionKerningData is not None - ): - remap = self._downConversionKerningData["groupRenameMap"] - remappedKerning = {} - for (side1, side2), value in list(kerning.items()): - side1 = remap.get(side1, side1) - side2 = remap.get(side2, side2) - remappedKerning[side1, side2] = value - kerning = remappedKerning - # pack and write - kerningDict = {} - for left, right in kerning.keys(): - value = kerning[left, right] - if left not in kerningDict: - kerningDict[left] = {} - kerningDict[left][right] = value - if kerningDict: - self._writePlist(KERNING_FILENAME, kerningDict) - elif self._havePreviousFile: - self.removePath(KERNING_FILENAME, force=True, removeEmptyParents=False) - - # lib.plist - - def writeLib(self, libDict, validate=None): - """ - Write lib.plist. This method requires a - lib dict as an argument. - - ``validate`` will validate the data, by default it is set to the - class's validate value, can be overridden. - """ - if validate is None: - validate = self._validate - if validate: - valid, message = fontLibValidator(libDict) - if not valid: - raise UFOLibError(message) - if libDict: - self._writePlist(LIB_FILENAME, libDict) - elif self._havePreviousFile: - self.removePath(LIB_FILENAME, force=True, removeEmptyParents=False) - - # features.fea - - def writeFeatures(self, features, validate=None): - """ - Write features.fea. This method requires a - features string as an argument. - """ - if validate is None: - validate = self._validate - if self._formatVersion == UFOFormatVersion.FORMAT_1_0: - raise UFOLibError("features.fea is not allowed in UFO Format Version 1.") - if validate: - if not isinstance(features, str): - raise UFOLibError("The features are not text.") - if features: - self.writeBytesToPath(FEATURES_FILENAME, features.encode("utf8")) - elif self._havePreviousFile: - self.removePath(FEATURES_FILENAME, force=True, removeEmptyParents=False) - - # glyph sets & layers - - def writeLayerContents(self, layerOrder=None, validate=None): - """ - Write the layercontents.plist file. This method *must* be called - after all glyph sets have been written. - """ - if validate is None: - validate = self._validate - if self._formatVersion < UFOFormatVersion.FORMAT_3_0: - return - if layerOrder is not None: - newOrder = [] - for layerName in layerOrder: - if layerName is None: - layerName = DEFAULT_LAYER_NAME - newOrder.append(layerName) - layerOrder = newOrder - else: - layerOrder = list(self.layerContents.keys()) - if validate and set(layerOrder) != set(self.layerContents.keys()): - raise UFOLibError("The layer order content does not match the glyph sets that have been created.") - layerContents = [(layerName, self.layerContents[layerName]) for layerName in layerOrder] - self._writePlist(LAYERCONTENTS_FILENAME, layerContents) - - def _findDirectoryForLayerName(self, layerName): - foundDirectory = None - for existingLayerName, directoryName in list(self.layerContents.items()): - if layerName is None and directoryName == DEFAULT_GLYPHS_DIRNAME: - foundDirectory = directoryName - break - elif existingLayerName == layerName: - foundDirectory = directoryName - break - if not foundDirectory: - raise UFOLibError("Could not locate a glyph set directory for the layer named %s." % layerName) - return foundDirectory - - def getGlyphSet( - self, - layerName=None, - defaultLayer=True, - glyphNameToFileNameFunc=None, - validateRead=None, - validateWrite=None, - expectContentsFile=False, - ): - """ - Return the GlyphSet object associated with the - appropriate glyph directory in the .ufo. - If layerName is None, the default glyph set - will be used. The defaultLayer flag indictes - that the layer should be saved into the default - glyphs directory. - - ``validateRead`` will validate the read data, by default it is set to the - class's validate value, can be overridden. - ``validateWrte`` will validate the written data, by default it is set to the - class's validate value, can be overridden. - ``expectContentsFile`` will raise a GlifLibError if a contents.plist file is - not found on the glyph set file system. This should be set to ``True`` if you - are reading an existing UFO and ``False`` if you use ``getGlyphSet`` to create - a fresh glyph set. - """ - if validateRead is None: - validateRead = self._validate - if validateWrite is None: - validateWrite = self._validate - # only default can be written in < 3 - if ( - self._formatVersion < UFOFormatVersion.FORMAT_3_0 - and (not defaultLayer or layerName is not None) - ): - raise UFOLibError( - f"Only the default layer can be writen in UFO {self._formatVersion.major}." - ) - # locate a layer name when None has been given - if layerName is None and defaultLayer: - for existingLayerName, directory in self.layerContents.items(): - if directory == DEFAULT_GLYPHS_DIRNAME: - layerName = existingLayerName - if layerName is None: - layerName = DEFAULT_LAYER_NAME - elif layerName is None and not defaultLayer: - raise UFOLibError("A layer name must be provided for non-default layers.") - # move along to format specific writing - if self._formatVersion < UFOFormatVersion.FORMAT_3_0: - return self._getDefaultGlyphSet( - validateRead, - validateWrite, - glyphNameToFileNameFunc=glyphNameToFileNameFunc, - expectContentsFile=expectContentsFile - ) - elif self._formatVersion.major == UFOFormatVersion.FORMAT_3_0.major: - return self._getGlyphSetFormatVersion3( - validateRead, - validateWrite, - layerName=layerName, - defaultLayer=defaultLayer, - glyphNameToFileNameFunc=glyphNameToFileNameFunc, - expectContentsFile=expectContentsFile, - ) - else: - raise NotImplementedError(self._formatVersion) - - def _getDefaultGlyphSet( - self, - validateRead, - validateWrite, - glyphNameToFileNameFunc=None, - expectContentsFile=False, - ): - from fontTools.ufoLib.glifLib import GlyphSet - - glyphSubFS = self.fs.makedir(DEFAULT_GLYPHS_DIRNAME, recreate=True) - return GlyphSet( - glyphSubFS, - glyphNameToFileNameFunc=glyphNameToFileNameFunc, - ufoFormatVersion=self._formatVersion, - validateRead=validateRead, - validateWrite=validateWrite, - expectContentsFile=expectContentsFile, - ) - - def _getGlyphSetFormatVersion3( - self, - validateRead, - validateWrite, - layerName=None, - defaultLayer=True, - glyphNameToFileNameFunc=None, - expectContentsFile=False, - ): - from fontTools.ufoLib.glifLib import GlyphSet - - # if the default flag is on, make sure that the default in the file - # matches the default being written. also make sure that this layer - # name is not already linked to a non-default layer. - if defaultLayer: - for existingLayerName, directory in self.layerContents.items(): - if directory == DEFAULT_GLYPHS_DIRNAME: - if existingLayerName != layerName: - raise UFOLibError( - "Another layer ('%s') is already mapped to the default directory." - % existingLayerName - ) - elif existingLayerName == layerName: - raise UFOLibError("The layer name is already mapped to a non-default layer.") - # get an existing directory name - if layerName in self.layerContents: - directory = self.layerContents[layerName] - # get a new directory name - else: - if defaultLayer: - directory = DEFAULT_GLYPHS_DIRNAME - else: - # not caching this could be slightly expensive, - # but caching it will be cumbersome - existing = {d.lower() for d in self.layerContents.values()} - directory = userNameToFileName(layerName, existing=existing, prefix="glyphs.") - # make the directory - glyphSubFS = self.fs.makedir(directory, recreate=True) - # store the mapping - self.layerContents[layerName] = directory - # load the glyph set - return GlyphSet( - glyphSubFS, - glyphNameToFileNameFunc=glyphNameToFileNameFunc, - ufoFormatVersion=self._formatVersion, - validateRead=validateRead, - validateWrite=validateWrite, - expectContentsFile=expectContentsFile, - ) - - def renameGlyphSet(self, layerName, newLayerName, defaultLayer=False): - """ - Rename a glyph set. - - Note: if a GlyphSet object has already been retrieved for - layerName, it is up to the caller to inform that object that - the directory it represents has changed. - """ - if self._formatVersion < UFOFormatVersion.FORMAT_3_0: - # ignore renaming glyph sets for UFO1 UFO2 - # just write the data from the default layer - return - # the new and old names can be the same - # as long as the default is being switched - if layerName == newLayerName: - # if the default is off and the layer is already not the default, skip - if self.layerContents[layerName] != DEFAULT_GLYPHS_DIRNAME and not defaultLayer: - return - # if the default is on and the layer is already the default, skip - if self.layerContents[layerName] == DEFAULT_GLYPHS_DIRNAME and defaultLayer: - return - else: - # make sure the new layer name doesn't already exist - if newLayerName is None: - newLayerName = DEFAULT_LAYER_NAME - if newLayerName in self.layerContents: - raise UFOLibError("A layer named %s already exists." % newLayerName) - # make sure the default layer doesn't already exist - if defaultLayer and DEFAULT_GLYPHS_DIRNAME in self.layerContents.values(): - raise UFOLibError("A default layer already exists.") - # get the paths - oldDirectory = self._findDirectoryForLayerName(layerName) - if defaultLayer: - newDirectory = DEFAULT_GLYPHS_DIRNAME - else: - existing = {name.lower() for name in self.layerContents.values()} - newDirectory = userNameToFileName(newLayerName, existing=existing, prefix="glyphs.") - # update the internal mapping - del self.layerContents[layerName] - self.layerContents[newLayerName] = newDirectory - # do the file system copy - self.fs.movedir(oldDirectory, newDirectory, create=True) - - def deleteGlyphSet(self, layerName): - """ - Remove the glyph set matching layerName. - """ - if self._formatVersion < UFOFormatVersion.FORMAT_3_0: - # ignore deleting glyph sets for UFO1 UFO2 as there are no layers - # just write the data from the default layer - return - foundDirectory = self._findDirectoryForLayerName(layerName) - self.removePath(foundDirectory, removeEmptyParents=False) - del self.layerContents[layerName] - - def writeData(self, fileName, data): - """ - Write data to fileName in the 'data' directory. - The data must be a bytes string. - """ - self.writeBytesToPath(f"{DATA_DIRNAME}/{fsdecode(fileName)}", data) - - def removeData(self, fileName): - """ - Remove the file named fileName from the data directory. - """ - self.removePath(f"{DATA_DIRNAME}/{fsdecode(fileName)}") - - # /images - - def writeImage(self, fileName, data, validate=None): - """ - Write data to fileName in the images directory. - The data must be a valid PNG. - """ - if validate is None: - validate = self._validate - if self._formatVersion < UFOFormatVersion.FORMAT_3_0: - raise UFOLibError( - f"Images are not allowed in UFO {self._formatVersion.major}." - ) - fileName = fsdecode(fileName) - if validate: - valid, error = pngValidator(data=data) - if not valid: - raise UFOLibError(error) - self.writeBytesToPath(f"{IMAGES_DIRNAME}/{fileName}", data) - - def removeImage(self, fileName, validate=None): # XXX remove unused 'validate'? - """ - Remove the file named fileName from the - images directory. - """ - if self._formatVersion < UFOFormatVersion.FORMAT_3_0: - raise UFOLibError( - f"Images are not allowed in UFO {self._formatVersion.major}." - ) - self.removePath(f"{IMAGES_DIRNAME}/{fsdecode(fileName)}") - - def copyImageFromReader(self, reader, sourceFileName, destFileName, validate=None): - """ - Copy the sourceFileName in the provided UFOReader to destFileName - in this writer. This uses the most memory efficient method possible - for copying the data possible. - """ - if validate is None: - validate = self._validate - if self._formatVersion < UFOFormatVersion.FORMAT_3_0: - raise UFOLibError( - f"Images are not allowed in UFO {self._formatVersion.major}." - ) - sourcePath = f"{IMAGES_DIRNAME}/{fsdecode(sourceFileName)}" - destPath = f"{IMAGES_DIRNAME}/{fsdecode(destFileName)}" - self.copyFromReader(reader, sourcePath, destPath) - - def close(self): - if self._havePreviousFile and self._fileStructure is UFOFileStructure.ZIP: - # if we are updating an existing zip file, we can now compress the - # contents of the temporary filesystem in the destination path - rootDir = os.path.splitext(os.path.basename(self._path))[0] + ".ufo" - with fs.zipfs.ZipFS(self._path, write=True, encoding="utf-8") as destFS: - fs.copy.copy_fs(self.fs, destFS.makedir(rootDir)) - super().close() + """ + Write the various components of the .ufo. + + By default, the written data will be validated before writing. Set ``validate`` to + ``False`` if you do not want to validate the data. Validation can also be overriden + on a per method level if desired. + + The ``formatVersion`` argument allows to specify the UFO format version as a tuple + of integers (major, minor), or as a single integer for the major digit only (minor + is implied as 0). By default the latest formatVersion will be used; currently it's + 3.0, which is equivalent to formatVersion=(3, 0). + + An UnsupportedUFOFormat exception is raised if the requested UFO formatVersion is + not supported. + """ + + def __init__( + self, + path, + formatVersion=None, + fileCreator="com.github.fonttools.ufoLib", + structure=None, + validate=True, + ): + try: + formatVersion = UFOFormatVersion(formatVersion) + except ValueError as e: + from fontTools.ufoLib.errors import UnsupportedUFOFormat + + raise UnsupportedUFOFormat( + f"Unsupported UFO format: {formatVersion!r}" + ) from e + + if hasattr(path, "__fspath__"): # support os.PathLike objects + path = path.__fspath__() + + if isinstance(path, str): + # normalize path by removing trailing or double slashes + path = os.path.normpath(path) + havePreviousFile = os.path.exists(path) + if havePreviousFile: + # ensure we use the same structure as the destination + existingStructure = _sniffFileStructure(path) + if structure is not None: + try: + structure = UFOFileStructure(structure) + except ValueError: + raise UFOLibError( + "Invalid or unsupported structure: '%s'" % structure + ) + if structure is not existingStructure: + raise UFOLibError( + "A UFO with a different structure (%s) already exists " + "at the given path: '%s'" % (existingStructure, path) + ) + else: + structure = existingStructure + else: + # if not exists, default to 'package' structure + if structure is None: + structure = UFOFileStructure.PACKAGE + dirName = os.path.dirname(path) + if dirName and not os.path.isdir(dirName): + raise UFOLibError( + "Cannot write to '%s': directory does not exist" % path + ) + if structure is UFOFileStructure.ZIP: + if havePreviousFile: + # we can't write a zip in-place, so we have to copy its + # contents to a temporary location and work from there, then + # upon closing UFOWriter we create the final zip file + parentFS = fs.tempfs.TempFS() + with fs.zipfs.ZipFS(path, encoding="utf-8") as origFS: + fs.copy.copy_fs(origFS, parentFS) + # if output path is an existing zip, we require that it contains + # one, and only one, root directory (with arbitrary name), in turn + # containing all the existing UFO contents + rootDirs = [ + p.name + for p in parentFS.scandir("/") + # exclude macOS metadata contained in zip file + if p.is_dir and p.name != "__MACOSX" + ] + if len(rootDirs) != 1: + raise UFOLibError( + "Expected exactly 1 root directory, found %d" + % len(rootDirs) + ) + else: + # 'ClosingSubFS' ensures that the parent filesystem is closed + # when its root subdirectory is closed + self.fs = parentFS.opendir( + rootDirs[0], factory=fs.subfs.ClosingSubFS + ) + else: + # if the output zip file didn't exist, we create the root folder; + # we name it the same as input 'path', but with '.ufo' extension + rootDir = os.path.splitext(os.path.basename(path))[0] + ".ufo" + parentFS = fs.zipfs.ZipFS(path, write=True, encoding="utf-8") + parentFS.makedir(rootDir) + self.fs = parentFS.opendir(rootDir, factory=fs.subfs.ClosingSubFS) + else: + self.fs = fs.osfs.OSFS(path, create=True) + self._fileStructure = structure + self._havePreviousFile = havePreviousFile + self._shouldClose = True + elif isinstance(path, fs.base.FS): + filesystem = path + try: + filesystem.check() + except fs.errors.FilesystemClosed: + raise UFOLibError("the filesystem '%s' is closed" % path) + else: + self.fs = filesystem + try: + path = filesystem.getsyspath("/") + except fs.errors.NoSysPath: + # network or in-memory FS may not map to the local one + path = str(filesystem) + # if passed an FS object, always use 'package' structure + if structure and structure is not UFOFileStructure.PACKAGE: + import warnings + + warnings.warn( + "The 'structure' argument is not used when input is an FS object", + UserWarning, + stacklevel=2, + ) + self._fileStructure = UFOFileStructure.PACKAGE + # if FS contains a "metainfo.plist", we consider it non-empty + self._havePreviousFile = filesystem.exists(METAINFO_FILENAME) + # the user is responsible for closing the FS object + self._shouldClose = False + else: + raise TypeError( + "Expected a path string or fs object, found %s" % type(path).__name__ + ) + + # establish some basic stuff + self._path = fsdecode(path) + self._formatVersion = formatVersion + self._fileCreator = fileCreator + self._downConversionKerningData = None + self._validate = validate + # if the file already exists, get the format version. + # this will be needed for up and down conversion. + previousFormatVersion = None + if self._havePreviousFile: + metaInfo = self._readMetaInfo(validate=validate) + previousFormatVersion = metaInfo["formatVersionTuple"] + # catch down conversion + if previousFormatVersion > formatVersion: + from fontTools.ufoLib.errors import UnsupportedUFOFormat + + raise UnsupportedUFOFormat( + "The UFO located at this path is a higher version " + f"({previousFormatVersion}) than the version ({formatVersion}) " + "that is trying to be written. This is not supported." + ) + # handle the layer contents + self.layerContents = {} + if previousFormatVersion is not None and previousFormatVersion.major >= 3: + # already exists + self.layerContents = OrderedDict(self._readLayerContents(validate)) + else: + # previous < 3 + # imply the layer contents + if self.fs.exists(DEFAULT_GLYPHS_DIRNAME): + self.layerContents = {DEFAULT_LAYER_NAME: DEFAULT_GLYPHS_DIRNAME} + # write the new metainfo + self._writeMetaInfo() + + # properties + + def _get_fileCreator(self): + return self._fileCreator + + fileCreator = property( + _get_fileCreator, + doc="The file creator of the UFO. This is set into metainfo.plist during __init__.", + ) + + # support methods for file system interaction + + def copyFromReader(self, reader, sourcePath, destPath): + """ + Copy the sourcePath in the provided UFOReader to destPath + in this writer. The paths must be relative. This works with + both individual files and directories. + """ + if not isinstance(reader, UFOReader): + raise UFOLibError("The reader must be an instance of UFOReader.") + sourcePath = fsdecode(sourcePath) + destPath = fsdecode(destPath) + if not reader.fs.exists(sourcePath): + raise UFOLibError( + 'The reader does not have data located at "%s".' % sourcePath + ) + if self.fs.exists(destPath): + raise UFOLibError('A file named "%s" already exists.' % destPath) + # create the destination directory if it doesn't exist + self.fs.makedirs(fs.path.dirname(destPath), recreate=True) + if reader.fs.isdir(sourcePath): + fs.copy.copy_dir(reader.fs, sourcePath, self.fs, destPath) + else: + fs.copy.copy_file(reader.fs, sourcePath, self.fs, destPath) + + def writeBytesToPath(self, path, data): + """ + Write bytes to a path relative to the UFO filesystem's root. + If writing to an existing UFO, check to see if data matches the data + that is already in the file at path; if so, the file is not rewritten + so that the modification date is preserved. + If needed, the directory tree for the given path will be built. + """ + path = fsdecode(path) + if self._havePreviousFile: + if self.fs.isfile(path) and data == self.fs.readbytes(path): + return + try: + self.fs.writebytes(path, data) + except fs.errors.FileExpected: + raise UFOLibError("A directory exists at '%s'" % path) + except fs.errors.ResourceNotFound: + self.fs.makedirs(fs.path.dirname(path), recreate=True) + self.fs.writebytes(path, data) + + def getFileObjectForPath(self, path, mode="w", encoding=None): + """ + Returns a file (or file-like) object for the + file at the given path. The path must be relative + to the UFO path. Returns None if the file does + not exist and the mode is "r" or "rb. + An encoding may be passed if the file is opened in text mode. + + Note: The caller is responsible for closing the open file. + """ + path = fsdecode(path) + try: + return self.fs.open(path, mode=mode, encoding=encoding) + except fs.errors.ResourceNotFound as e: + m = mode[0] + if m == "r": + # XXX I think we should just let it raise. The docstring, + # however, says that this returns None if mode is 'r' + return None + elif m == "w" or m == "a" or m == "x": + self.fs.makedirs(fs.path.dirname(path), recreate=True) + return self.fs.open(path, mode=mode, encoding=encoding) + except fs.errors.ResourceError as e: + return UFOLibError(f"unable to open '{path}' on {self.fs}: {e}") + + def removePath(self, path, force=False, removeEmptyParents=True): + """ + Remove the file (or directory) at path. The path + must be relative to the UFO. + Raises UFOLibError if the path doesn't exist. + If force=True, ignore non-existent paths. + If the directory where 'path' is located becomes empty, it will + be automatically removed, unless 'removeEmptyParents' is False. + """ + path = fsdecode(path) + try: + self.fs.remove(path) + except fs.errors.FileExpected: + self.fs.removetree(path) + except fs.errors.ResourceNotFound: + if not force: + raise UFOLibError(f"'{path}' does not exist on {self.fs}") + if removeEmptyParents: + parent = fs.path.dirname(path) + if parent: + fs.tools.remove_empty(self.fs, parent) + + # alias kept for backward compatibility with old API + removeFileForPath = removePath + + # UFO mod time + + def setModificationTime(self): + """ + Set the UFO modification time to the current time. + This is never called automatically. It is up to the + caller to call this when finished working on the UFO. + """ + path = self._path + if path is not None and os.path.exists(path): + try: + # this may fail on some filesystems (e.g. SMB servers) + os.utime(path, None) + except OSError as e: + logger.warning("Failed to set modified time: %s", e) + + # metainfo.plist + + def _writeMetaInfo(self): + metaInfo = dict( + creator=self._fileCreator, + formatVersion=self._formatVersion.major, + ) + if self._formatVersion.minor != 0: + metaInfo["formatVersionMinor"] = self._formatVersion.minor + self._writePlist(METAINFO_FILENAME, metaInfo) + + # groups.plist + + def setKerningGroupConversionRenameMaps(self, maps): + """ + Set maps defining the renaming that should be done + when writing groups and kerning in UFO 1 and UFO 2. + This will effectively undo the conversion done when + UFOReader reads this data. The dictionary should have + this form:: + + { + "side1" : {"group name to use when writing" : "group name in data"}, + "side2" : {"group name to use when writing" : "group name in data"} + } + + This is the same form returned by UFOReader's + getKerningGroupConversionRenameMaps method. + """ + if self._formatVersion >= UFOFormatVersion.FORMAT_3_0: + return # XXX raise an error here + # flip the dictionaries + remap = {} + for side in ("side1", "side2"): + for writeName, dataName in list(maps[side].items()): + remap[dataName] = writeName + self._downConversionKerningData = dict(groupRenameMap=remap) + + def writeGroups(self, groups, validate=None): + """ + Write groups.plist. This method requires a + dict of glyph groups as an argument. + + ``validate`` will validate the data, by default it is set to the + class's validate value, can be overridden. + """ + if validate is None: + validate = self._validate + # validate the data structure + if validate: + valid, message = groupsValidator(groups) + if not valid: + raise UFOLibError(message) + # down convert + if ( + self._formatVersion < UFOFormatVersion.FORMAT_3_0 + and self._downConversionKerningData is not None + ): + remap = self._downConversionKerningData["groupRenameMap"] + remappedGroups = {} + # there are some edge cases here that are ignored: + # 1. if a group is being renamed to a name that + # already exists, the existing group is always + # overwritten. (this is why there are two loops + # below.) there doesn't seem to be a logical + # solution to groups mismatching and overwriting + # with the specifiecd group seems like a better + # solution than throwing an error. + # 2. if side 1 and side 2 groups are being renamed + # to the same group name there is no check to + # ensure that the contents are identical. that + # is left up to the caller. + for name, contents in list(groups.items()): + if name in remap: + continue + remappedGroups[name] = contents + for name, contents in list(groups.items()): + if name not in remap: + continue + name = remap[name] + remappedGroups[name] = contents + groups = remappedGroups + # pack and write + groupsNew = {} + for key, value in groups.items(): + groupsNew[key] = list(value) + if groupsNew: + self._writePlist(GROUPS_FILENAME, groupsNew) + elif self._havePreviousFile: + self.removePath(GROUPS_FILENAME, force=True, removeEmptyParents=False) + + # fontinfo.plist + + def writeInfo(self, info, validate=None): + """ + Write info.plist. This method requires an object + that supports getting attributes that follow the + fontinfo.plist version 2 specification. Attributes + will be taken from the given object and written + into the file. + + ``validate`` will validate the data, by default it is set to the + class's validate value, can be overridden. + """ + if validate is None: + validate = self._validate + # gather version 3 data + infoData = {} + for attr in list(fontInfoAttributesVersion3ValueData.keys()): + if hasattr(info, attr): + try: + value = getattr(info, attr) + except AttributeError: + raise UFOLibError( + "The supplied info object does not support getting a necessary attribute (%s)." + % attr + ) + if value is None: + continue + infoData[attr] = value + # down convert data if necessary and validate + if self._formatVersion == UFOFormatVersion.FORMAT_3_0: + if validate: + infoData = validateInfoVersion3Data(infoData) + elif self._formatVersion == UFOFormatVersion.FORMAT_2_0: + infoData = _convertFontInfoDataVersion3ToVersion2(infoData) + if validate: + infoData = validateInfoVersion2Data(infoData) + elif self._formatVersion == UFOFormatVersion.FORMAT_1_0: + infoData = _convertFontInfoDataVersion3ToVersion2(infoData) + if validate: + infoData = validateInfoVersion2Data(infoData) + infoData = _convertFontInfoDataVersion2ToVersion1(infoData) + # write file if there is anything to write + if infoData: + self._writePlist(FONTINFO_FILENAME, infoData) + + # kerning.plist + + def writeKerning(self, kerning, validate=None): + """ + Write kerning.plist. This method requires a + dict of kerning pairs as an argument. + + This performs basic structural validation of the kerning, + but it does not check for compliance with the spec in + regards to conflicting pairs. The assumption is that the + kerning data being passed is standards compliant. + + ``validate`` will validate the data, by default it is set to the + class's validate value, can be overridden. + """ + if validate is None: + validate = self._validate + # validate the data structure + if validate: + invalidFormatMessage = "The kerning is not properly formatted." + if not isDictEnough(kerning): + raise UFOLibError(invalidFormatMessage) + for pair, value in list(kerning.items()): + if not isinstance(pair, (list, tuple)): + raise UFOLibError(invalidFormatMessage) + if not len(pair) == 2: + raise UFOLibError(invalidFormatMessage) + if not isinstance(pair[0], str): + raise UFOLibError(invalidFormatMessage) + if not isinstance(pair[1], str): + raise UFOLibError(invalidFormatMessage) + if not isinstance(value, numberTypes): + raise UFOLibError(invalidFormatMessage) + # down convert + if ( + self._formatVersion < UFOFormatVersion.FORMAT_3_0 + and self._downConversionKerningData is not None + ): + remap = self._downConversionKerningData["groupRenameMap"] + remappedKerning = {} + for (side1, side2), value in list(kerning.items()): + side1 = remap.get(side1, side1) + side2 = remap.get(side2, side2) + remappedKerning[side1, side2] = value + kerning = remappedKerning + # pack and write + kerningDict = {} + for left, right in kerning.keys(): + value = kerning[left, right] + if left not in kerningDict: + kerningDict[left] = {} + kerningDict[left][right] = value + if kerningDict: + self._writePlist(KERNING_FILENAME, kerningDict) + elif self._havePreviousFile: + self.removePath(KERNING_FILENAME, force=True, removeEmptyParents=False) + + # lib.plist + + def writeLib(self, libDict, validate=None): + """ + Write lib.plist. This method requires a + lib dict as an argument. + + ``validate`` will validate the data, by default it is set to the + class's validate value, can be overridden. + """ + if validate is None: + validate = self._validate + if validate: + valid, message = fontLibValidator(libDict) + if not valid: + raise UFOLibError(message) + if libDict: + self._writePlist(LIB_FILENAME, libDict) + elif self._havePreviousFile: + self.removePath(LIB_FILENAME, force=True, removeEmptyParents=False) + + # features.fea + + def writeFeatures(self, features, validate=None): + """ + Write features.fea. This method requires a + features string as an argument. + """ + if validate is None: + validate = self._validate + if self._formatVersion == UFOFormatVersion.FORMAT_1_0: + raise UFOLibError("features.fea is not allowed in UFO Format Version 1.") + if validate: + if not isinstance(features, str): + raise UFOLibError("The features are not text.") + if features: + self.writeBytesToPath(FEATURES_FILENAME, features.encode("utf8")) + elif self._havePreviousFile: + self.removePath(FEATURES_FILENAME, force=True, removeEmptyParents=False) + + # glyph sets & layers + + def writeLayerContents(self, layerOrder=None, validate=None): + """ + Write the layercontents.plist file. This method *must* be called + after all glyph sets have been written. + """ + if validate is None: + validate = self._validate + if self._formatVersion < UFOFormatVersion.FORMAT_3_0: + return + if layerOrder is not None: + newOrder = [] + for layerName in layerOrder: + if layerName is None: + layerName = DEFAULT_LAYER_NAME + newOrder.append(layerName) + layerOrder = newOrder + else: + layerOrder = list(self.layerContents.keys()) + if validate and set(layerOrder) != set(self.layerContents.keys()): + raise UFOLibError( + "The layer order content does not match the glyph sets that have been created." + ) + layerContents = [ + (layerName, self.layerContents[layerName]) for layerName in layerOrder + ] + self._writePlist(LAYERCONTENTS_FILENAME, layerContents) + + def _findDirectoryForLayerName(self, layerName): + foundDirectory = None + for existingLayerName, directoryName in list(self.layerContents.items()): + if layerName is None and directoryName == DEFAULT_GLYPHS_DIRNAME: + foundDirectory = directoryName + break + elif existingLayerName == layerName: + foundDirectory = directoryName + break + if not foundDirectory: + raise UFOLibError( + "Could not locate a glyph set directory for the layer named %s." + % layerName + ) + return foundDirectory + + def getGlyphSet( + self, + layerName=None, + defaultLayer=True, + glyphNameToFileNameFunc=None, + validateRead=None, + validateWrite=None, + expectContentsFile=False, + ): + """ + Return the GlyphSet object associated with the + appropriate glyph directory in the .ufo. + If layerName is None, the default glyph set + will be used. The defaultLayer flag indictes + that the layer should be saved into the default + glyphs directory. + + ``validateRead`` will validate the read data, by default it is set to the + class's validate value, can be overridden. + ``validateWrte`` will validate the written data, by default it is set to the + class's validate value, can be overridden. + ``expectContentsFile`` will raise a GlifLibError if a contents.plist file is + not found on the glyph set file system. This should be set to ``True`` if you + are reading an existing UFO and ``False`` if you use ``getGlyphSet`` to create + a fresh glyph set. + """ + if validateRead is None: + validateRead = self._validate + if validateWrite is None: + validateWrite = self._validate + # only default can be written in < 3 + if self._formatVersion < UFOFormatVersion.FORMAT_3_0 and ( + not defaultLayer or layerName is not None + ): + raise UFOLibError( + f"Only the default layer can be writen in UFO {self._formatVersion.major}." + ) + # locate a layer name when None has been given + if layerName is None and defaultLayer: + for existingLayerName, directory in self.layerContents.items(): + if directory == DEFAULT_GLYPHS_DIRNAME: + layerName = existingLayerName + if layerName is None: + layerName = DEFAULT_LAYER_NAME + elif layerName is None and not defaultLayer: + raise UFOLibError("A layer name must be provided for non-default layers.") + # move along to format specific writing + if self._formatVersion < UFOFormatVersion.FORMAT_3_0: + return self._getDefaultGlyphSet( + validateRead, + validateWrite, + glyphNameToFileNameFunc=glyphNameToFileNameFunc, + expectContentsFile=expectContentsFile, + ) + elif self._formatVersion.major == UFOFormatVersion.FORMAT_3_0.major: + return self._getGlyphSetFormatVersion3( + validateRead, + validateWrite, + layerName=layerName, + defaultLayer=defaultLayer, + glyphNameToFileNameFunc=glyphNameToFileNameFunc, + expectContentsFile=expectContentsFile, + ) + else: + raise NotImplementedError(self._formatVersion) + + def _getDefaultGlyphSet( + self, + validateRead, + validateWrite, + glyphNameToFileNameFunc=None, + expectContentsFile=False, + ): + from fontTools.ufoLib.glifLib import GlyphSet + + glyphSubFS = self.fs.makedir(DEFAULT_GLYPHS_DIRNAME, recreate=True) + return GlyphSet( + glyphSubFS, + glyphNameToFileNameFunc=glyphNameToFileNameFunc, + ufoFormatVersion=self._formatVersion, + validateRead=validateRead, + validateWrite=validateWrite, + expectContentsFile=expectContentsFile, + ) + + def _getGlyphSetFormatVersion3( + self, + validateRead, + validateWrite, + layerName=None, + defaultLayer=True, + glyphNameToFileNameFunc=None, + expectContentsFile=False, + ): + from fontTools.ufoLib.glifLib import GlyphSet + + # if the default flag is on, make sure that the default in the file + # matches the default being written. also make sure that this layer + # name is not already linked to a non-default layer. + if defaultLayer: + for existingLayerName, directory in self.layerContents.items(): + if directory == DEFAULT_GLYPHS_DIRNAME: + if existingLayerName != layerName: + raise UFOLibError( + "Another layer ('%s') is already mapped to the default directory." + % existingLayerName + ) + elif existingLayerName == layerName: + raise UFOLibError( + "The layer name is already mapped to a non-default layer." + ) + # get an existing directory name + if layerName in self.layerContents: + directory = self.layerContents[layerName] + # get a new directory name + else: + if defaultLayer: + directory = DEFAULT_GLYPHS_DIRNAME + else: + # not caching this could be slightly expensive, + # but caching it will be cumbersome + existing = {d.lower() for d in self.layerContents.values()} + directory = userNameToFileName( + layerName, existing=existing, prefix="glyphs." + ) + # make the directory + glyphSubFS = self.fs.makedir(directory, recreate=True) + # store the mapping + self.layerContents[layerName] = directory + # load the glyph set + return GlyphSet( + glyphSubFS, + glyphNameToFileNameFunc=glyphNameToFileNameFunc, + ufoFormatVersion=self._formatVersion, + validateRead=validateRead, + validateWrite=validateWrite, + expectContentsFile=expectContentsFile, + ) + + def renameGlyphSet(self, layerName, newLayerName, defaultLayer=False): + """ + Rename a glyph set. + + Note: if a GlyphSet object has already been retrieved for + layerName, it is up to the caller to inform that object that + the directory it represents has changed. + """ + if self._formatVersion < UFOFormatVersion.FORMAT_3_0: + # ignore renaming glyph sets for UFO1 UFO2 + # just write the data from the default layer + return + # the new and old names can be the same + # as long as the default is being switched + if layerName == newLayerName: + # if the default is off and the layer is already not the default, skip + if ( + self.layerContents[layerName] != DEFAULT_GLYPHS_DIRNAME + and not defaultLayer + ): + return + # if the default is on and the layer is already the default, skip + if self.layerContents[layerName] == DEFAULT_GLYPHS_DIRNAME and defaultLayer: + return + else: + # make sure the new layer name doesn't already exist + if newLayerName is None: + newLayerName = DEFAULT_LAYER_NAME + if newLayerName in self.layerContents: + raise UFOLibError("A layer named %s already exists." % newLayerName) + # make sure the default layer doesn't already exist + if defaultLayer and DEFAULT_GLYPHS_DIRNAME in self.layerContents.values(): + raise UFOLibError("A default layer already exists.") + # get the paths + oldDirectory = self._findDirectoryForLayerName(layerName) + if defaultLayer: + newDirectory = DEFAULT_GLYPHS_DIRNAME + else: + existing = {name.lower() for name in self.layerContents.values()} + newDirectory = userNameToFileName( + newLayerName, existing=existing, prefix="glyphs." + ) + # update the internal mapping + del self.layerContents[layerName] + self.layerContents[newLayerName] = newDirectory + # do the file system copy + self.fs.movedir(oldDirectory, newDirectory, create=True) + + def deleteGlyphSet(self, layerName): + """ + Remove the glyph set matching layerName. + """ + if self._formatVersion < UFOFormatVersion.FORMAT_3_0: + # ignore deleting glyph sets for UFO1 UFO2 as there are no layers + # just write the data from the default layer + return + foundDirectory = self._findDirectoryForLayerName(layerName) + self.removePath(foundDirectory, removeEmptyParents=False) + del self.layerContents[layerName] + + def writeData(self, fileName, data): + """ + Write data to fileName in the 'data' directory. + The data must be a bytes string. + """ + self.writeBytesToPath(f"{DATA_DIRNAME}/{fsdecode(fileName)}", data) + + def removeData(self, fileName): + """ + Remove the file named fileName from the data directory. + """ + self.removePath(f"{DATA_DIRNAME}/{fsdecode(fileName)}") + + # /images + + def writeImage(self, fileName, data, validate=None): + """ + Write data to fileName in the images directory. + The data must be a valid PNG. + """ + if validate is None: + validate = self._validate + if self._formatVersion < UFOFormatVersion.FORMAT_3_0: + raise UFOLibError( + f"Images are not allowed in UFO {self._formatVersion.major}." + ) + fileName = fsdecode(fileName) + if validate: + valid, error = pngValidator(data=data) + if not valid: + raise UFOLibError(error) + self.writeBytesToPath(f"{IMAGES_DIRNAME}/{fileName}", data) + + def removeImage(self, fileName, validate=None): # XXX remove unused 'validate'? + """ + Remove the file named fileName from the + images directory. + """ + if self._formatVersion < UFOFormatVersion.FORMAT_3_0: + raise UFOLibError( + f"Images are not allowed in UFO {self._formatVersion.major}." + ) + self.removePath(f"{IMAGES_DIRNAME}/{fsdecode(fileName)}") + + def copyImageFromReader(self, reader, sourceFileName, destFileName, validate=None): + """ + Copy the sourceFileName in the provided UFOReader to destFileName + in this writer. This uses the most memory efficient method possible + for copying the data possible. + """ + if validate is None: + validate = self._validate + if self._formatVersion < UFOFormatVersion.FORMAT_3_0: + raise UFOLibError( + f"Images are not allowed in UFO {self._formatVersion.major}." + ) + sourcePath = f"{IMAGES_DIRNAME}/{fsdecode(sourceFileName)}" + destPath = f"{IMAGES_DIRNAME}/{fsdecode(destFileName)}" + self.copyFromReader(reader, sourcePath, destPath) + + def close(self): + if self._havePreviousFile and self._fileStructure is UFOFileStructure.ZIP: + # if we are updating an existing zip file, we can now compress the + # contents of the temporary filesystem in the destination path + rootDir = os.path.splitext(os.path.basename(self._path))[0] + ".ufo" + with fs.zipfs.ZipFS(self._path, write=True, encoding="utf-8") as destFS: + fs.copy.copy_fs(self.fs, destFS.makedir(rootDir)) + super().close() # just an alias, makes it more explicit @@ -1699,38 +1733,39 @@ UFOReaderWriter = UFOWriter def _sniffFileStructure(ufo_path): - """Return UFOFileStructure.ZIP if the UFO at path 'ufo_path' (str) - is a zip file, else return UFOFileStructure.PACKAGE if 'ufo_path' is a - directory. - Raise UFOLibError if it is a file with unknown structure, or if the path - does not exist. - """ - if zipfile.is_zipfile(ufo_path): - return UFOFileStructure.ZIP - elif os.path.isdir(ufo_path): - return UFOFileStructure.PACKAGE - elif os.path.isfile(ufo_path): - raise UFOLibError( - "The specified UFO does not have a known structure: '%s'" % ufo_path - ) - else: - raise UFOLibError("No such file or directory: '%s'" % ufo_path) + """Return UFOFileStructure.ZIP if the UFO at path 'ufo_path' (str) + is a zip file, else return UFOFileStructure.PACKAGE if 'ufo_path' is a + directory. + Raise UFOLibError if it is a file with unknown structure, or if the path + does not exist. + """ + if zipfile.is_zipfile(ufo_path): + return UFOFileStructure.ZIP + elif os.path.isdir(ufo_path): + return UFOFileStructure.PACKAGE + elif os.path.isfile(ufo_path): + raise UFOLibError( + "The specified UFO does not have a known structure: '%s'" % ufo_path + ) + else: + raise UFOLibError("No such file or directory: '%s'" % ufo_path) def makeUFOPath(path): - """ - Return a .ufo pathname. - - >>> makeUFOPath("directory/something.ext") == ( - ... os.path.join('directory', 'something.ufo')) - True - >>> makeUFOPath("directory/something.another.thing.ext") == ( - ... os.path.join('directory', 'something.another.thing.ufo')) - True - """ - dir, name = os.path.split(path) - name = ".".join([".".join(name.split(".")[:-1]), "ufo"]) - return os.path.join(dir, name) + """ + Return a .ufo pathname. + + >>> makeUFOPath("directory/something.ext") == ( + ... os.path.join('directory', 'something.ufo')) + True + >>> makeUFOPath("directory/something.another.thing.ext") == ( + ... os.path.join('directory', 'something.another.thing.ufo')) + True + """ + dir, name = os.path.split(path) + name = ".".join([".".join(name.split(".")[:-1]), "ufo"]) + return os.path.join(dir, name) + # ---------------------- # fontinfo.plist Support @@ -1742,93 +1777,98 @@ def makeUFOPath(path): # The version 1 spec was very loose and there were numerous # cases of invalid values. + def validateFontInfoVersion2ValueForAttribute(attr, value): - """ - This performs very basic validation of the value for attribute - following the UFO 2 fontinfo.plist specification. The results - of this should not be interpretted as *correct* for the font - that they are part of. This merely indicates that the value - is of the proper type and, where the specification defines - a set range of possible values for an attribute, that the - value is in the accepted range. - """ - dataValidationDict = fontInfoAttributesVersion2ValueData[attr] - valueType = dataValidationDict.get("type") - validator = dataValidationDict.get("valueValidator") - valueOptions = dataValidationDict.get("valueOptions") - # have specific options for the validator - if valueOptions is not None: - isValidValue = validator(value, valueOptions) - # no specific options - else: - if validator == genericTypeValidator: - isValidValue = validator(value, valueType) - else: - isValidValue = validator(value) - return isValidValue + """ + This performs very basic validation of the value for attribute + following the UFO 2 fontinfo.plist specification. The results + of this should not be interpretted as *correct* for the font + that they are part of. This merely indicates that the value + is of the proper type and, where the specification defines + a set range of possible values for an attribute, that the + value is in the accepted range. + """ + dataValidationDict = fontInfoAttributesVersion2ValueData[attr] + valueType = dataValidationDict.get("type") + validator = dataValidationDict.get("valueValidator") + valueOptions = dataValidationDict.get("valueOptions") + # have specific options for the validator + if valueOptions is not None: + isValidValue = validator(value, valueOptions) + # no specific options + else: + if validator == genericTypeValidator: + isValidValue = validator(value, valueType) + else: + isValidValue = validator(value) + return isValidValue + def validateInfoVersion2Data(infoData): - """ - This performs very basic validation of the value for infoData - following the UFO 2 fontinfo.plist specification. The results - of this should not be interpretted as *correct* for the font - that they are part of. This merely indicates that the values - are of the proper type and, where the specification defines - a set range of possible values for an attribute, that the - value is in the accepted range. - """ - validInfoData = {} - for attr, value in list(infoData.items()): - isValidValue = validateFontInfoVersion2ValueForAttribute(attr, value) - if not isValidValue: - raise UFOLibError(f"Invalid value for attribute {attr} ({value!r}).") - else: - validInfoData[attr] = value - return validInfoData + """ + This performs very basic validation of the value for infoData + following the UFO 2 fontinfo.plist specification. The results + of this should not be interpretted as *correct* for the font + that they are part of. This merely indicates that the values + are of the proper type and, where the specification defines + a set range of possible values for an attribute, that the + value is in the accepted range. + """ + validInfoData = {} + for attr, value in list(infoData.items()): + isValidValue = validateFontInfoVersion2ValueForAttribute(attr, value) + if not isValidValue: + raise UFOLibError(f"Invalid value for attribute {attr} ({value!r}).") + else: + validInfoData[attr] = value + return validInfoData + def validateFontInfoVersion3ValueForAttribute(attr, value): - """ - This performs very basic validation of the value for attribute - following the UFO 3 fontinfo.plist specification. The results - of this should not be interpretted as *correct* for the font - that they are part of. This merely indicates that the value - is of the proper type and, where the specification defines - a set range of possible values for an attribute, that the - value is in the accepted range. - """ - dataValidationDict = fontInfoAttributesVersion3ValueData[attr] - valueType = dataValidationDict.get("type") - validator = dataValidationDict.get("valueValidator") - valueOptions = dataValidationDict.get("valueOptions") - # have specific options for the validator - if valueOptions is not None: - isValidValue = validator(value, valueOptions) - # no specific options - else: - if validator == genericTypeValidator: - isValidValue = validator(value, valueType) - else: - isValidValue = validator(value) - return isValidValue + """ + This performs very basic validation of the value for attribute + following the UFO 3 fontinfo.plist specification. The results + of this should not be interpretted as *correct* for the font + that they are part of. This merely indicates that the value + is of the proper type and, where the specification defines + a set range of possible values for an attribute, that the + value is in the accepted range. + """ + dataValidationDict = fontInfoAttributesVersion3ValueData[attr] + valueType = dataValidationDict.get("type") + validator = dataValidationDict.get("valueValidator") + valueOptions = dataValidationDict.get("valueOptions") + # have specific options for the validator + if valueOptions is not None: + isValidValue = validator(value, valueOptions) + # no specific options + else: + if validator == genericTypeValidator: + isValidValue = validator(value, valueType) + else: + isValidValue = validator(value) + return isValidValue + def validateInfoVersion3Data(infoData): - """ - This performs very basic validation of the value for infoData - following the UFO 3 fontinfo.plist specification. The results - of this should not be interpretted as *correct* for the font - that they are part of. This merely indicates that the values - are of the proper type and, where the specification defines - a set range of possible values for an attribute, that the - value is in the accepted range. - """ - validInfoData = {} - for attr, value in list(infoData.items()): - isValidValue = validateFontInfoVersion3ValueForAttribute(attr, value) - if not isValidValue: - raise UFOLibError(f"Invalid value for attribute {attr} ({value!r}).") - else: - validInfoData[attr] = value - return validInfoData + """ + This performs very basic validation of the value for infoData + following the UFO 3 fontinfo.plist specification. The results + of this should not be interpretted as *correct* for the font + that they are part of. This merely indicates that the values + are of the proper type and, where the specification defines + a set range of possible values for an attribute, that the + value is in the accepted range. + """ + validInfoData = {} + for attr, value in list(infoData.items()): + isValidValue = validateFontInfoVersion3ValueForAttribute(attr, value) + if not isValidValue: + raise UFOLibError(f"Invalid value for attribute {attr} ({value!r}).") + else: + validInfoData[attr] = value + return validInfoData + # Value Options @@ -1844,264 +1884,346 @@ fontInfoOpenTypeOS2TypeOptions = [0, 1, 2, 3, 8, 9] # fontinfo.plist. fontInfoAttributesVersion1 = { - "familyName", - "styleName", - "fullName", - "fontName", - "menuName", - "fontStyle", - "note", - "versionMajor", - "versionMinor", - "year", - "copyright", - "notice", - "trademark", - "license", - "licenseURL", - "createdBy", - "designer", - "designerURL", - "vendorURL", - "unitsPerEm", - "ascender", - "descender", - "capHeight", - "xHeight", - "defaultWidth", - "slantAngle", - "italicAngle", - "widthName", - "weightName", - "weightValue", - "fondName", - "otFamilyName", - "otStyleName", - "otMacName", - "msCharSet", - "fondID", - "uniqueID", - "ttVendor", - "ttUniqueID", - "ttVersion", + "familyName", + "styleName", + "fullName", + "fontName", + "menuName", + "fontStyle", + "note", + "versionMajor", + "versionMinor", + "year", + "copyright", + "notice", + "trademark", + "license", + "licenseURL", + "createdBy", + "designer", + "designerURL", + "vendorURL", + "unitsPerEm", + "ascender", + "descender", + "capHeight", + "xHeight", + "defaultWidth", + "slantAngle", + "italicAngle", + "widthName", + "weightName", + "weightValue", + "fondName", + "otFamilyName", + "otStyleName", + "otMacName", + "msCharSet", + "fondID", + "uniqueID", + "ttVendor", + "ttUniqueID", + "ttVersion", } fontInfoAttributesVersion2ValueData = { - "familyName" : dict(type=str), - "styleName" : dict(type=str), - "styleMapFamilyName" : dict(type=str), - "styleMapStyleName" : dict(type=str, valueValidator=fontInfoStyleMapStyleNameValidator), - "versionMajor" : dict(type=int), - "versionMinor" : dict(type=int), - "year" : dict(type=int), - "copyright" : dict(type=str), - "trademark" : dict(type=str), - "unitsPerEm" : dict(type=(int, float)), - "descender" : dict(type=(int, float)), - "xHeight" : dict(type=(int, float)), - "capHeight" : dict(type=(int, float)), - "ascender" : dict(type=(int, float)), - "italicAngle" : dict(type=(float, int)), - "note" : dict(type=str), - "openTypeHeadCreated" : dict(type=str, valueValidator=fontInfoOpenTypeHeadCreatedValidator), - "openTypeHeadLowestRecPPEM" : dict(type=(int, float)), - "openTypeHeadFlags" : dict(type="integerList", valueValidator=genericIntListValidator, valueOptions=fontInfoOpenTypeHeadFlagsOptions), - "openTypeHheaAscender" : dict(type=(int, float)), - "openTypeHheaDescender" : dict(type=(int, float)), - "openTypeHheaLineGap" : dict(type=(int, float)), - "openTypeHheaCaretSlopeRise" : dict(type=int), - "openTypeHheaCaretSlopeRun" : dict(type=int), - "openTypeHheaCaretOffset" : dict(type=(int, float)), - "openTypeNameDesigner" : dict(type=str), - "openTypeNameDesignerURL" : dict(type=str), - "openTypeNameManufacturer" : dict(type=str), - "openTypeNameManufacturerURL" : dict(type=str), - "openTypeNameLicense" : dict(type=str), - "openTypeNameLicenseURL" : dict(type=str), - "openTypeNameVersion" : dict(type=str), - "openTypeNameUniqueID" : dict(type=str), - "openTypeNameDescription" : dict(type=str), - "openTypeNamePreferredFamilyName" : dict(type=str), - "openTypeNamePreferredSubfamilyName" : dict(type=str), - "openTypeNameCompatibleFullName" : dict(type=str), - "openTypeNameSampleText" : dict(type=str), - "openTypeNameWWSFamilyName" : dict(type=str), - "openTypeNameWWSSubfamilyName" : dict(type=str), - "openTypeOS2WidthClass" : dict(type=int, valueValidator=fontInfoOpenTypeOS2WidthClassValidator), - "openTypeOS2WeightClass" : dict(type=int, valueValidator=fontInfoOpenTypeOS2WeightClassValidator), - "openTypeOS2Selection" : dict(type="integerList", valueValidator=genericIntListValidator, valueOptions=fontInfoOpenTypeOS2SelectionOptions), - "openTypeOS2VendorID" : dict(type=str), - "openTypeOS2Panose" : dict(type="integerList", valueValidator=fontInfoVersion2OpenTypeOS2PanoseValidator), - "openTypeOS2FamilyClass" : dict(type="integerList", valueValidator=fontInfoOpenTypeOS2FamilyClassValidator), - "openTypeOS2UnicodeRanges" : dict(type="integerList", valueValidator=genericIntListValidator, valueOptions=fontInfoOpenTypeOS2UnicodeRangesOptions), - "openTypeOS2CodePageRanges" : dict(type="integerList", valueValidator=genericIntListValidator, valueOptions=fontInfoOpenTypeOS2CodePageRangesOptions), - "openTypeOS2TypoAscender" : dict(type=(int, float)), - "openTypeOS2TypoDescender" : dict(type=(int, float)), - "openTypeOS2TypoLineGap" : dict(type=(int, float)), - "openTypeOS2WinAscent" : dict(type=(int, float)), - "openTypeOS2WinDescent" : dict(type=(int, float)), - "openTypeOS2Type" : dict(type="integerList", valueValidator=genericIntListValidator, valueOptions=fontInfoOpenTypeOS2TypeOptions), - "openTypeOS2SubscriptXSize" : dict(type=(int, float)), - "openTypeOS2SubscriptYSize" : dict(type=(int, float)), - "openTypeOS2SubscriptXOffset" : dict(type=(int, float)), - "openTypeOS2SubscriptYOffset" : dict(type=(int, float)), - "openTypeOS2SuperscriptXSize" : dict(type=(int, float)), - "openTypeOS2SuperscriptYSize" : dict(type=(int, float)), - "openTypeOS2SuperscriptXOffset" : dict(type=(int, float)), - "openTypeOS2SuperscriptYOffset" : dict(type=(int, float)), - "openTypeOS2StrikeoutSize" : dict(type=(int, float)), - "openTypeOS2StrikeoutPosition" : dict(type=(int, float)), - "openTypeVheaVertTypoAscender" : dict(type=(int, float)), - "openTypeVheaVertTypoDescender" : dict(type=(int, float)), - "openTypeVheaVertTypoLineGap" : dict(type=(int, float)), - "openTypeVheaCaretSlopeRise" : dict(type=int), - "openTypeVheaCaretSlopeRun" : dict(type=int), - "openTypeVheaCaretOffset" : dict(type=(int, float)), - "postscriptFontName" : dict(type=str), - "postscriptFullName" : dict(type=str), - "postscriptSlantAngle" : dict(type=(float, int)), - "postscriptUniqueID" : dict(type=int), - "postscriptUnderlineThickness" : dict(type=(int, float)), - "postscriptUnderlinePosition" : dict(type=(int, float)), - "postscriptIsFixedPitch" : dict(type=bool), - "postscriptBlueValues" : dict(type="integerList", valueValidator=fontInfoPostscriptBluesValidator), - "postscriptOtherBlues" : dict(type="integerList", valueValidator=fontInfoPostscriptOtherBluesValidator), - "postscriptFamilyBlues" : dict(type="integerList", valueValidator=fontInfoPostscriptBluesValidator), - "postscriptFamilyOtherBlues" : dict(type="integerList", valueValidator=fontInfoPostscriptOtherBluesValidator), - "postscriptStemSnapH" : dict(type="integerList", valueValidator=fontInfoPostscriptStemsValidator), - "postscriptStemSnapV" : dict(type="integerList", valueValidator=fontInfoPostscriptStemsValidator), - "postscriptBlueFuzz" : dict(type=(int, float)), - "postscriptBlueShift" : dict(type=(int, float)), - "postscriptBlueScale" : dict(type=(float, int)), - "postscriptForceBold" : dict(type=bool), - "postscriptDefaultWidthX" : dict(type=(int, float)), - "postscriptNominalWidthX" : dict(type=(int, float)), - "postscriptWeightName" : dict(type=str), - "postscriptDefaultCharacter" : dict(type=str), - "postscriptWindowsCharacterSet" : dict(type=int, valueValidator=fontInfoPostscriptWindowsCharacterSetValidator), - "macintoshFONDFamilyID" : dict(type=int), - "macintoshFONDName" : dict(type=str), + "familyName": dict(type=str), + "styleName": dict(type=str), + "styleMapFamilyName": dict(type=str), + "styleMapStyleName": dict( + type=str, valueValidator=fontInfoStyleMapStyleNameValidator + ), + "versionMajor": dict(type=int), + "versionMinor": dict(type=int), + "year": dict(type=int), + "copyright": dict(type=str), + "trademark": dict(type=str), + "unitsPerEm": dict(type=(int, float)), + "descender": dict(type=(int, float)), + "xHeight": dict(type=(int, float)), + "capHeight": dict(type=(int, float)), + "ascender": dict(type=(int, float)), + "italicAngle": dict(type=(float, int)), + "note": dict(type=str), + "openTypeHeadCreated": dict( + type=str, valueValidator=fontInfoOpenTypeHeadCreatedValidator + ), + "openTypeHeadLowestRecPPEM": dict(type=(int, float)), + "openTypeHeadFlags": dict( + type="integerList", + valueValidator=genericIntListValidator, + valueOptions=fontInfoOpenTypeHeadFlagsOptions, + ), + "openTypeHheaAscender": dict(type=(int, float)), + "openTypeHheaDescender": dict(type=(int, float)), + "openTypeHheaLineGap": dict(type=(int, float)), + "openTypeHheaCaretSlopeRise": dict(type=int), + "openTypeHheaCaretSlopeRun": dict(type=int), + "openTypeHheaCaretOffset": dict(type=(int, float)), + "openTypeNameDesigner": dict(type=str), + "openTypeNameDesignerURL": dict(type=str), + "openTypeNameManufacturer": dict(type=str), + "openTypeNameManufacturerURL": dict(type=str), + "openTypeNameLicense": dict(type=str), + "openTypeNameLicenseURL": dict(type=str), + "openTypeNameVersion": dict(type=str), + "openTypeNameUniqueID": dict(type=str), + "openTypeNameDescription": dict(type=str), + "openTypeNamePreferredFamilyName": dict(type=str), + "openTypeNamePreferredSubfamilyName": dict(type=str), + "openTypeNameCompatibleFullName": dict(type=str), + "openTypeNameSampleText": dict(type=str), + "openTypeNameWWSFamilyName": dict(type=str), + "openTypeNameWWSSubfamilyName": dict(type=str), + "openTypeOS2WidthClass": dict( + type=int, valueValidator=fontInfoOpenTypeOS2WidthClassValidator + ), + "openTypeOS2WeightClass": dict( + type=int, valueValidator=fontInfoOpenTypeOS2WeightClassValidator + ), + "openTypeOS2Selection": dict( + type="integerList", + valueValidator=genericIntListValidator, + valueOptions=fontInfoOpenTypeOS2SelectionOptions, + ), + "openTypeOS2VendorID": dict(type=str), + "openTypeOS2Panose": dict( + type="integerList", valueValidator=fontInfoVersion2OpenTypeOS2PanoseValidator + ), + "openTypeOS2FamilyClass": dict( + type="integerList", valueValidator=fontInfoOpenTypeOS2FamilyClassValidator + ), + "openTypeOS2UnicodeRanges": dict( + type="integerList", + valueValidator=genericIntListValidator, + valueOptions=fontInfoOpenTypeOS2UnicodeRangesOptions, + ), + "openTypeOS2CodePageRanges": dict( + type="integerList", + valueValidator=genericIntListValidator, + valueOptions=fontInfoOpenTypeOS2CodePageRangesOptions, + ), + "openTypeOS2TypoAscender": dict(type=(int, float)), + "openTypeOS2TypoDescender": dict(type=(int, float)), + "openTypeOS2TypoLineGap": dict(type=(int, float)), + "openTypeOS2WinAscent": dict(type=(int, float)), + "openTypeOS2WinDescent": dict(type=(int, float)), + "openTypeOS2Type": dict( + type="integerList", + valueValidator=genericIntListValidator, + valueOptions=fontInfoOpenTypeOS2TypeOptions, + ), + "openTypeOS2SubscriptXSize": dict(type=(int, float)), + "openTypeOS2SubscriptYSize": dict(type=(int, float)), + "openTypeOS2SubscriptXOffset": dict(type=(int, float)), + "openTypeOS2SubscriptYOffset": dict(type=(int, float)), + "openTypeOS2SuperscriptXSize": dict(type=(int, float)), + "openTypeOS2SuperscriptYSize": dict(type=(int, float)), + "openTypeOS2SuperscriptXOffset": dict(type=(int, float)), + "openTypeOS2SuperscriptYOffset": dict(type=(int, float)), + "openTypeOS2StrikeoutSize": dict(type=(int, float)), + "openTypeOS2StrikeoutPosition": dict(type=(int, float)), + "openTypeVheaVertTypoAscender": dict(type=(int, float)), + "openTypeVheaVertTypoDescender": dict(type=(int, float)), + "openTypeVheaVertTypoLineGap": dict(type=(int, float)), + "openTypeVheaCaretSlopeRise": dict(type=int), + "openTypeVheaCaretSlopeRun": dict(type=int), + "openTypeVheaCaretOffset": dict(type=(int, float)), + "postscriptFontName": dict(type=str), + "postscriptFullName": dict(type=str), + "postscriptSlantAngle": dict(type=(float, int)), + "postscriptUniqueID": dict(type=int), + "postscriptUnderlineThickness": dict(type=(int, float)), + "postscriptUnderlinePosition": dict(type=(int, float)), + "postscriptIsFixedPitch": dict(type=bool), + "postscriptBlueValues": dict( + type="integerList", valueValidator=fontInfoPostscriptBluesValidator + ), + "postscriptOtherBlues": dict( + type="integerList", valueValidator=fontInfoPostscriptOtherBluesValidator + ), + "postscriptFamilyBlues": dict( + type="integerList", valueValidator=fontInfoPostscriptBluesValidator + ), + "postscriptFamilyOtherBlues": dict( + type="integerList", valueValidator=fontInfoPostscriptOtherBluesValidator + ), + "postscriptStemSnapH": dict( + type="integerList", valueValidator=fontInfoPostscriptStemsValidator + ), + "postscriptStemSnapV": dict( + type="integerList", valueValidator=fontInfoPostscriptStemsValidator + ), + "postscriptBlueFuzz": dict(type=(int, float)), + "postscriptBlueShift": dict(type=(int, float)), + "postscriptBlueScale": dict(type=(float, int)), + "postscriptForceBold": dict(type=bool), + "postscriptDefaultWidthX": dict(type=(int, float)), + "postscriptNominalWidthX": dict(type=(int, float)), + "postscriptWeightName": dict(type=str), + "postscriptDefaultCharacter": dict(type=str), + "postscriptWindowsCharacterSet": dict( + type=int, valueValidator=fontInfoPostscriptWindowsCharacterSetValidator + ), + "macintoshFONDFamilyID": dict(type=int), + "macintoshFONDName": dict(type=str), } fontInfoAttributesVersion2 = set(fontInfoAttributesVersion2ValueData.keys()) fontInfoAttributesVersion3ValueData = deepcopy(fontInfoAttributesVersion2ValueData) -fontInfoAttributesVersion3ValueData.update({ - "versionMinor" : dict(type=int, valueValidator=genericNonNegativeIntValidator), - "unitsPerEm" : dict(type=(int, float), valueValidator=genericNonNegativeNumberValidator), - "openTypeHeadLowestRecPPEM" : dict(type=int, valueValidator=genericNonNegativeNumberValidator), - "openTypeHheaAscender" : dict(type=int), - "openTypeHheaDescender" : dict(type=int), - "openTypeHheaLineGap" : dict(type=int), - "openTypeHheaCaretOffset" : dict(type=int), - "openTypeOS2Panose" : dict(type="integerList", valueValidator=fontInfoVersion3OpenTypeOS2PanoseValidator), - "openTypeOS2TypoAscender" : dict(type=int), - "openTypeOS2TypoDescender" : dict(type=int), - "openTypeOS2TypoLineGap" : dict(type=int), - "openTypeOS2WinAscent" : dict(type=int, valueValidator=genericNonNegativeNumberValidator), - "openTypeOS2WinDescent" : dict(type=int, valueValidator=genericNonNegativeNumberValidator), - "openTypeOS2SubscriptXSize" : dict(type=int), - "openTypeOS2SubscriptYSize" : dict(type=int), - "openTypeOS2SubscriptXOffset" : dict(type=int), - "openTypeOS2SubscriptYOffset" : dict(type=int), - "openTypeOS2SuperscriptXSize" : dict(type=int), - "openTypeOS2SuperscriptYSize" : dict(type=int), - "openTypeOS2SuperscriptXOffset" : dict(type=int), - "openTypeOS2SuperscriptYOffset" : dict(type=int), - "openTypeOS2StrikeoutSize" : dict(type=int), - "openTypeOS2StrikeoutPosition" : dict(type=int), - "openTypeGaspRangeRecords" : dict(type="dictList", valueValidator=fontInfoOpenTypeGaspRangeRecordsValidator), - "openTypeNameRecords" : dict(type="dictList", valueValidator=fontInfoOpenTypeNameRecordsValidator), - "openTypeVheaVertTypoAscender" : dict(type=int), - "openTypeVheaVertTypoDescender" : dict(type=int), - "openTypeVheaVertTypoLineGap" : dict(type=int), - "openTypeVheaCaretOffset" : dict(type=int), - "woffMajorVersion" : dict(type=int, valueValidator=genericNonNegativeIntValidator), - "woffMinorVersion" : dict(type=int, valueValidator=genericNonNegativeIntValidator), - "woffMetadataUniqueID" : dict(type=dict, valueValidator=fontInfoWOFFMetadataUniqueIDValidator), - "woffMetadataVendor" : dict(type=dict, valueValidator=fontInfoWOFFMetadataVendorValidator), - "woffMetadataCredits" : dict(type=dict, valueValidator=fontInfoWOFFMetadataCreditsValidator), - "woffMetadataDescription" : dict(type=dict, valueValidator=fontInfoWOFFMetadataDescriptionValidator), - "woffMetadataLicense" : dict(type=dict, valueValidator=fontInfoWOFFMetadataLicenseValidator), - "woffMetadataCopyright" : dict(type=dict, valueValidator=fontInfoWOFFMetadataCopyrightValidator), - "woffMetadataTrademark" : dict(type=dict, valueValidator=fontInfoWOFFMetadataTrademarkValidator), - "woffMetadataLicensee" : dict(type=dict, valueValidator=fontInfoWOFFMetadataLicenseeValidator), - "woffMetadataExtensions" : dict(type=list, valueValidator=fontInfoWOFFMetadataExtensionsValidator), - "guidelines" : dict(type=list, valueValidator=guidelinesValidator) -}) +fontInfoAttributesVersion3ValueData.update( + { + "versionMinor": dict(type=int, valueValidator=genericNonNegativeIntValidator), + "unitsPerEm": dict( + type=(int, float), valueValidator=genericNonNegativeNumberValidator + ), + "openTypeHeadLowestRecPPEM": dict( + type=int, valueValidator=genericNonNegativeNumberValidator + ), + "openTypeHheaAscender": dict(type=int), + "openTypeHheaDescender": dict(type=int), + "openTypeHheaLineGap": dict(type=int), + "openTypeHheaCaretOffset": dict(type=int), + "openTypeOS2Panose": dict( + type="integerList", + valueValidator=fontInfoVersion3OpenTypeOS2PanoseValidator, + ), + "openTypeOS2TypoAscender": dict(type=int), + "openTypeOS2TypoDescender": dict(type=int), + "openTypeOS2TypoLineGap": dict(type=int), + "openTypeOS2WinAscent": dict( + type=int, valueValidator=genericNonNegativeNumberValidator + ), + "openTypeOS2WinDescent": dict( + type=int, valueValidator=genericNonNegativeNumberValidator + ), + "openTypeOS2SubscriptXSize": dict(type=int), + "openTypeOS2SubscriptYSize": dict(type=int), + "openTypeOS2SubscriptXOffset": dict(type=int), + "openTypeOS2SubscriptYOffset": dict(type=int), + "openTypeOS2SuperscriptXSize": dict(type=int), + "openTypeOS2SuperscriptYSize": dict(type=int), + "openTypeOS2SuperscriptXOffset": dict(type=int), + "openTypeOS2SuperscriptYOffset": dict(type=int), + "openTypeOS2StrikeoutSize": dict(type=int), + "openTypeOS2StrikeoutPosition": dict(type=int), + "openTypeGaspRangeRecords": dict( + type="dictList", valueValidator=fontInfoOpenTypeGaspRangeRecordsValidator + ), + "openTypeNameRecords": dict( + type="dictList", valueValidator=fontInfoOpenTypeNameRecordsValidator + ), + "openTypeVheaVertTypoAscender": dict(type=int), + "openTypeVheaVertTypoDescender": dict(type=int), + "openTypeVheaVertTypoLineGap": dict(type=int), + "openTypeVheaCaretOffset": dict(type=int), + "woffMajorVersion": dict( + type=int, valueValidator=genericNonNegativeIntValidator + ), + "woffMinorVersion": dict( + type=int, valueValidator=genericNonNegativeIntValidator + ), + "woffMetadataUniqueID": dict( + type=dict, valueValidator=fontInfoWOFFMetadataUniqueIDValidator + ), + "woffMetadataVendor": dict( + type=dict, valueValidator=fontInfoWOFFMetadataVendorValidator + ), + "woffMetadataCredits": dict( + type=dict, valueValidator=fontInfoWOFFMetadataCreditsValidator + ), + "woffMetadataDescription": dict( + type=dict, valueValidator=fontInfoWOFFMetadataDescriptionValidator + ), + "woffMetadataLicense": dict( + type=dict, valueValidator=fontInfoWOFFMetadataLicenseValidator + ), + "woffMetadataCopyright": dict( + type=dict, valueValidator=fontInfoWOFFMetadataCopyrightValidator + ), + "woffMetadataTrademark": dict( + type=dict, valueValidator=fontInfoWOFFMetadataTrademarkValidator + ), + "woffMetadataLicensee": dict( + type=dict, valueValidator=fontInfoWOFFMetadataLicenseeValidator + ), + "woffMetadataExtensions": dict( + type=list, valueValidator=fontInfoWOFFMetadataExtensionsValidator + ), + "guidelines": dict(type=list, valueValidator=guidelinesValidator), + } +) fontInfoAttributesVersion3 = set(fontInfoAttributesVersion3ValueData.keys()) # insert the type validator for all attrs that # have no defined validator. for attr, dataDict in list(fontInfoAttributesVersion2ValueData.items()): - if "valueValidator" not in dataDict: - dataDict["valueValidator"] = genericTypeValidator + if "valueValidator" not in dataDict: + dataDict["valueValidator"] = genericTypeValidator for attr, dataDict in list(fontInfoAttributesVersion3ValueData.items()): - if "valueValidator" not in dataDict: - dataDict["valueValidator"] = genericTypeValidator + if "valueValidator" not in dataDict: + dataDict["valueValidator"] = genericTypeValidator # Version Conversion Support # These are used from converting from version 1 # to version 2 or vice-versa. + def _flipDict(d): - flipped = {} - for key, value in list(d.items()): - flipped[value] = key - return flipped + flipped = {} + for key, value in list(d.items()): + flipped[value] = key + return flipped + fontInfoAttributesVersion1To2 = { - "menuName" : "styleMapFamilyName", - "designer" : "openTypeNameDesigner", - "designerURL" : "openTypeNameDesignerURL", - "createdBy" : "openTypeNameManufacturer", - "vendorURL" : "openTypeNameManufacturerURL", - "license" : "openTypeNameLicense", - "licenseURL" : "openTypeNameLicenseURL", - "ttVersion" : "openTypeNameVersion", - "ttUniqueID" : "openTypeNameUniqueID", - "notice" : "openTypeNameDescription", - "otFamilyName" : "openTypeNamePreferredFamilyName", - "otStyleName" : "openTypeNamePreferredSubfamilyName", - "otMacName" : "openTypeNameCompatibleFullName", - "weightName" : "postscriptWeightName", - "weightValue" : "openTypeOS2WeightClass", - "ttVendor" : "openTypeOS2VendorID", - "uniqueID" : "postscriptUniqueID", - "fontName" : "postscriptFontName", - "fondID" : "macintoshFONDFamilyID", - "fondName" : "macintoshFONDName", - "defaultWidth" : "postscriptDefaultWidthX", - "slantAngle" : "postscriptSlantAngle", - "fullName" : "postscriptFullName", - # require special value conversion - "fontStyle" : "styleMapStyleName", - "widthName" : "openTypeOS2WidthClass", - "msCharSet" : "postscriptWindowsCharacterSet" + "menuName": "styleMapFamilyName", + "designer": "openTypeNameDesigner", + "designerURL": "openTypeNameDesignerURL", + "createdBy": "openTypeNameManufacturer", + "vendorURL": "openTypeNameManufacturerURL", + "license": "openTypeNameLicense", + "licenseURL": "openTypeNameLicenseURL", + "ttVersion": "openTypeNameVersion", + "ttUniqueID": "openTypeNameUniqueID", + "notice": "openTypeNameDescription", + "otFamilyName": "openTypeNamePreferredFamilyName", + "otStyleName": "openTypeNamePreferredSubfamilyName", + "otMacName": "openTypeNameCompatibleFullName", + "weightName": "postscriptWeightName", + "weightValue": "openTypeOS2WeightClass", + "ttVendor": "openTypeOS2VendorID", + "uniqueID": "postscriptUniqueID", + "fontName": "postscriptFontName", + "fondID": "macintoshFONDFamilyID", + "fondName": "macintoshFONDName", + "defaultWidth": "postscriptDefaultWidthX", + "slantAngle": "postscriptSlantAngle", + "fullName": "postscriptFullName", + # require special value conversion + "fontStyle": "styleMapStyleName", + "widthName": "openTypeOS2WidthClass", + "msCharSet": "postscriptWindowsCharacterSet", } fontInfoAttributesVersion2To1 = _flipDict(fontInfoAttributesVersion1To2) deprecatedFontInfoAttributesVersion2 = set(fontInfoAttributesVersion1To2.keys()) -_fontStyle1To2 = { - 64 : "regular", - 1 : "italic", - 32 : "bold", - 33 : "bold italic" -} +_fontStyle1To2 = {64: "regular", 1: "italic", 32: "bold", 33: "bold italic"} _fontStyle2To1 = _flipDict(_fontStyle1To2) # Some UFO 1 files have 0 _fontStyle1To2[0] = "regular" _widthName1To2 = { - "Ultra-condensed" : 1, - "Extra-condensed" : 2, - "Condensed" : 3, - "Semi-condensed" : 4, - "Medium (normal)" : 5, - "Semi-expanded" : 6, - "Expanded" : 7, - "Extra-expanded" : 8, - "Ultra-expanded" : 9 + "Ultra-condensed": 1, + "Extra-condensed": 2, + "Condensed": 3, + "Semi-condensed": 4, + "Medium (normal)": 5, + "Semi-expanded": 6, + "Expanded": 7, + "Extra-expanded": 8, + "Ultra-expanded": 9, } _widthName2To1 = _flipDict(_widthName1To2) # FontLab's default width value is "Normal". @@ -2116,198 +2238,227 @@ _widthName1To2["medium"] = 5 _widthName1To2["Medium"] = 5 _msCharSet1To2 = { - 0 : 1, - 1 : 2, - 2 : 3, - 77 : 4, - 128 : 5, - 129 : 6, - 130 : 7, - 134 : 8, - 136 : 9, - 161 : 10, - 162 : 11, - 163 : 12, - 177 : 13, - 178 : 14, - 186 : 15, - 200 : 16, - 204 : 17, - 222 : 18, - 238 : 19, - 255 : 20 + 0: 1, + 1: 2, + 2: 3, + 77: 4, + 128: 5, + 129: 6, + 130: 7, + 134: 8, + 136: 9, + 161: 10, + 162: 11, + 163: 12, + 177: 13, + 178: 14, + 186: 15, + 200: 16, + 204: 17, + 222: 18, + 238: 19, + 255: 20, } _msCharSet2To1 = _flipDict(_msCharSet1To2) # 1 <-> 2 + def convertFontInfoValueForAttributeFromVersion1ToVersion2(attr, value): - """ - Convert value from version 1 to version 2 format. - Returns the new attribute name and the converted value. - If the value is None, None will be returned for the new value. - """ - # convert floats to ints if possible - if isinstance(value, float): - if int(value) == value: - value = int(value) - if value is not None: - if attr == "fontStyle": - v = _fontStyle1To2.get(value) - if v is None: - raise UFOLibError(f"Cannot convert value ({value!r}) for attribute {attr}.") - value = v - elif attr == "widthName": - v = _widthName1To2.get(value) - if v is None: - raise UFOLibError(f"Cannot convert value ({value!r}) for attribute {attr}.") - value = v - elif attr == "msCharSet": - v = _msCharSet1To2.get(value) - if v is None: - raise UFOLibError(f"Cannot convert value ({value!r}) for attribute {attr}.") - value = v - attr = fontInfoAttributesVersion1To2.get(attr, attr) - return attr, value + """ + Convert value from version 1 to version 2 format. + Returns the new attribute name and the converted value. + If the value is None, None will be returned for the new value. + """ + # convert floats to ints if possible + if isinstance(value, float): + if int(value) == value: + value = int(value) + if value is not None: + if attr == "fontStyle": + v = _fontStyle1To2.get(value) + if v is None: + raise UFOLibError( + f"Cannot convert value ({value!r}) for attribute {attr}." + ) + value = v + elif attr == "widthName": + v = _widthName1To2.get(value) + if v is None: + raise UFOLibError( + f"Cannot convert value ({value!r}) for attribute {attr}." + ) + value = v + elif attr == "msCharSet": + v = _msCharSet1To2.get(value) + if v is None: + raise UFOLibError( + f"Cannot convert value ({value!r}) for attribute {attr}." + ) + value = v + attr = fontInfoAttributesVersion1To2.get(attr, attr) + return attr, value + def convertFontInfoValueForAttributeFromVersion2ToVersion1(attr, value): - """ - Convert value from version 2 to version 1 format. - Returns the new attribute name and the converted value. - If the value is None, None will be returned for the new value. - """ - if value is not None: - if attr == "styleMapStyleName": - value = _fontStyle2To1.get(value) - elif attr == "openTypeOS2WidthClass": - value = _widthName2To1.get(value) - elif attr == "postscriptWindowsCharacterSet": - value = _msCharSet2To1.get(value) - attr = fontInfoAttributesVersion2To1.get(attr, attr) - return attr, value + """ + Convert value from version 2 to version 1 format. + Returns the new attribute name and the converted value. + If the value is None, None will be returned for the new value. + """ + if value is not None: + if attr == "styleMapStyleName": + value = _fontStyle2To1.get(value) + elif attr == "openTypeOS2WidthClass": + value = _widthName2To1.get(value) + elif attr == "postscriptWindowsCharacterSet": + value = _msCharSet2To1.get(value) + attr = fontInfoAttributesVersion2To1.get(attr, attr) + return attr, value + def _convertFontInfoDataVersion1ToVersion2(data): - converted = {} - for attr, value in list(data.items()): - # FontLab gives -1 for the weightValue - # for fonts wil no defined value. Many - # format version 1 UFOs will have this. - if attr == "weightValue" and value == -1: - continue - newAttr, newValue = convertFontInfoValueForAttributeFromVersion1ToVersion2(attr, value) - # skip if the attribute is not part of version 2 - if newAttr not in fontInfoAttributesVersion2: - continue - # catch values that can't be converted - if value is None: - raise UFOLibError(f"Cannot convert value ({value!r}) for attribute {newAttr}.") - # store - converted[newAttr] = newValue - return converted + converted = {} + for attr, value in list(data.items()): + # FontLab gives -1 for the weightValue + # for fonts wil no defined value. Many + # format version 1 UFOs will have this. + if attr == "weightValue" and value == -1: + continue + newAttr, newValue = convertFontInfoValueForAttributeFromVersion1ToVersion2( + attr, value + ) + # skip if the attribute is not part of version 2 + if newAttr not in fontInfoAttributesVersion2: + continue + # catch values that can't be converted + if value is None: + raise UFOLibError( + f"Cannot convert value ({value!r}) for attribute {newAttr}." + ) + # store + converted[newAttr] = newValue + return converted + def _convertFontInfoDataVersion2ToVersion1(data): - converted = {} - for attr, value in list(data.items()): - newAttr, newValue = convertFontInfoValueForAttributeFromVersion2ToVersion1(attr, value) - # only take attributes that are registered for version 1 - if newAttr not in fontInfoAttributesVersion1: - continue - # catch values that can't be converted - if value is None: - raise UFOLibError(f"Cannot convert value ({value!r}) for attribute {newAttr}.") - # store - converted[newAttr] = newValue - return converted + converted = {} + for attr, value in list(data.items()): + newAttr, newValue = convertFontInfoValueForAttributeFromVersion2ToVersion1( + attr, value + ) + # only take attributes that are registered for version 1 + if newAttr not in fontInfoAttributesVersion1: + continue + # catch values that can't be converted + if value is None: + raise UFOLibError( + f"Cannot convert value ({value!r}) for attribute {newAttr}." + ) + # store + converted[newAttr] = newValue + return converted + # 2 <-> 3 _ufo2To3NonNegativeInt = { - "versionMinor", - "openTypeHeadLowestRecPPEM", - "openTypeOS2WinAscent", - "openTypeOS2WinDescent" + "versionMinor", + "openTypeHeadLowestRecPPEM", + "openTypeOS2WinAscent", + "openTypeOS2WinDescent", } _ufo2To3NonNegativeIntOrFloat = { - "unitsPerEm", + "unitsPerEm", } _ufo2To3FloatToInt = { - "openTypeHeadLowestRecPPEM", - "openTypeHheaAscender", - "openTypeHheaDescender", - "openTypeHheaLineGap", - "openTypeHheaCaretOffset", - "openTypeOS2TypoAscender", - "openTypeOS2TypoDescender", - "openTypeOS2TypoLineGap", - "openTypeOS2WinAscent", - "openTypeOS2WinDescent", - "openTypeOS2SubscriptXSize", - "openTypeOS2SubscriptYSize", - "openTypeOS2SubscriptXOffset", - "openTypeOS2SubscriptYOffset", - "openTypeOS2SuperscriptXSize", - "openTypeOS2SuperscriptYSize", - "openTypeOS2SuperscriptXOffset", - "openTypeOS2SuperscriptYOffset", - "openTypeOS2StrikeoutSize", - "openTypeOS2StrikeoutPosition", - "openTypeVheaVertTypoAscender", - "openTypeVheaVertTypoDescender", - "openTypeVheaVertTypoLineGap", - "openTypeVheaCaretOffset" + "openTypeHeadLowestRecPPEM", + "openTypeHheaAscender", + "openTypeHheaDescender", + "openTypeHheaLineGap", + "openTypeHheaCaretOffset", + "openTypeOS2TypoAscender", + "openTypeOS2TypoDescender", + "openTypeOS2TypoLineGap", + "openTypeOS2WinAscent", + "openTypeOS2WinDescent", + "openTypeOS2SubscriptXSize", + "openTypeOS2SubscriptYSize", + "openTypeOS2SubscriptXOffset", + "openTypeOS2SubscriptYOffset", + "openTypeOS2SuperscriptXSize", + "openTypeOS2SuperscriptYSize", + "openTypeOS2SuperscriptXOffset", + "openTypeOS2SuperscriptYOffset", + "openTypeOS2StrikeoutSize", + "openTypeOS2StrikeoutPosition", + "openTypeVheaVertTypoAscender", + "openTypeVheaVertTypoDescender", + "openTypeVheaVertTypoLineGap", + "openTypeVheaCaretOffset", } + def convertFontInfoValueForAttributeFromVersion2ToVersion3(attr, value): - """ - Convert value from version 2 to version 3 format. - Returns the new attribute name and the converted value. - If the value is None, None will be returned for the new value. - """ - if attr in _ufo2To3FloatToInt: - try: - value = round(value) - except (ValueError, TypeError): - raise UFOLibError("Could not convert value for %s." % attr) - if attr in _ufo2To3NonNegativeInt: - try: - value = int(abs(value)) - except (ValueError, TypeError): - raise UFOLibError("Could not convert value for %s." % attr) - elif attr in _ufo2To3NonNegativeIntOrFloat: - try: - v = float(abs(value)) - except (ValueError, TypeError): - raise UFOLibError("Could not convert value for %s." % attr) - if v == int(v): - v = int(v) - if v != value: - value = v - return attr, value + """ + Convert value from version 2 to version 3 format. + Returns the new attribute name and the converted value. + If the value is None, None will be returned for the new value. + """ + if attr in _ufo2To3FloatToInt: + try: + value = round(value) + except (ValueError, TypeError): + raise UFOLibError("Could not convert value for %s." % attr) + if attr in _ufo2To3NonNegativeInt: + try: + value = int(abs(value)) + except (ValueError, TypeError): + raise UFOLibError("Could not convert value for %s." % attr) + elif attr in _ufo2To3NonNegativeIntOrFloat: + try: + v = float(abs(value)) + except (ValueError, TypeError): + raise UFOLibError("Could not convert value for %s." % attr) + if v == int(v): + v = int(v) + if v != value: + value = v + return attr, value + def convertFontInfoValueForAttributeFromVersion3ToVersion2(attr, value): - """ - Convert value from version 3 to version 2 format. - Returns the new attribute name and the converted value. - If the value is None, None will be returned for the new value. - """ - return attr, value + """ + Convert value from version 3 to version 2 format. + Returns the new attribute name and the converted value. + If the value is None, None will be returned for the new value. + """ + return attr, value + def _convertFontInfoDataVersion3ToVersion2(data): - converted = {} - for attr, value in list(data.items()): - newAttr, newValue = convertFontInfoValueForAttributeFromVersion3ToVersion2(attr, value) - if newAttr not in fontInfoAttributesVersion2: - continue - converted[newAttr] = newValue - return converted + converted = {} + for attr, value in list(data.items()): + newAttr, newValue = convertFontInfoValueForAttributeFromVersion3ToVersion2( + attr, value + ) + if newAttr not in fontInfoAttributesVersion2: + continue + converted[newAttr] = newValue + return converted + def _convertFontInfoDataVersion2ToVersion3(data): - converted = {} - for attr, value in list(data.items()): - attr, value = convertFontInfoValueForAttributeFromVersion2ToVersion3(attr, value) - converted[attr] = value - return converted + converted = {} + for attr, value in list(data.items()): + attr, value = convertFontInfoValueForAttributeFromVersion2ToVersion3( + attr, value + ) + converted[attr] = value + return converted + if __name__ == "__main__": - import doctest - doctest.testmod() + import doctest + + doctest.testmod() |