| from __future__ import absolute_import, unicode_literals |
| import sys |
| import os |
| from copy import deepcopy |
| import logging |
| import zipfile |
| import enum |
| from collections import OrderedDict |
| import fs |
| import fs.base |
| import fs.subfs |
| import fs.errors |
| import fs.copy |
| import fs.osfs |
| import fs.zipfs |
| import fs.tempfs |
| import fs.tools |
| from fontTools.misc.py23 import basestring, unicode, tounicode |
| from fontTools.misc import plistlib |
| from fontTools.ufoLib.validators import * |
| from fontTools.ufoLib.filenames import userNameToFileName |
| from fontTools.ufoLib.converters import convertUFO1OrUFO2KerningToUFO3Kerning |
| from fontTools.ufoLib.errors import UFOLibError |
| from fontTools.ufoLib.utils import datetimeAsTimestamp, fsdecode, numberTypes |
| |
| """ |
| A library for importing .ufo files and their descendants. |
| Refer to http://unifiedfontobject.com for the UFO specification. |
| |
| The UFOReader and UFOWriter classes support versions 1, 2 and 3 |
| of the specification. |
| |
| Sets that list the font info attribute names for the fontinfo.plist |
| formats are available for external use. These are: |
| fontInfoAttributesVersion1 |
| fontInfoAttributesVersion2 |
| fontInfoAttributesVersion3 |
| |
| A set listing the fontinfo.plist attributes that were deprecated |
| in version 2 is available for external use: |
| deprecatedFontInfoAttributesVersion2 |
| |
| Functions that do basic validation on values for fontinfo.plist |
| are available for external use. These are |
| validateFontInfoVersion2ValueForAttribute |
| validateFontInfoVersion3ValueForAttribute |
| |
| Value conversion functions are available for converting |
| fontinfo.plist values between the possible format versions. |
| convertFontInfoValueForAttributeFromVersion1ToVersion2 |
| convertFontInfoValueForAttributeFromVersion2ToVersion1 |
| convertFontInfoValueForAttributeFromVersion2ToVersion3 |
| convertFontInfoValueForAttributeFromVersion3ToVersion2 |
| """ |
| |
| __all__ = [ |
| "makeUFOPath", |
| "UFOLibError", |
| "UFOReader", |
| "UFOWriter", |
| "UFOReaderWriter", |
| "UFOFileStructure", |
| "fontInfoAttributesVersion1", |
| "fontInfoAttributesVersion2", |
| "fontInfoAttributesVersion3", |
| "deprecatedFontInfoAttributesVersion2", |
| "validateFontInfoVersion2ValueForAttribute", |
| "validateFontInfoVersion3ValueForAttribute", |
| "convertFontInfoValueForAttributeFromVersion1ToVersion2", |
| "convertFontInfoValueForAttributeFromVersion2ToVersion1" |
| ] |
| |
| __version__ = "3.0.0" |
| |
| |
| logger = logging.getLogger(__name__) |
| |
| |
| # --------- |
| # Constants |
| # --------- |
| |
| DEFAULT_GLYPHS_DIRNAME = "glyphs" |
| DATA_DIRNAME = "data" |
| IMAGES_DIRNAME = "images" |
| METAINFO_FILENAME = "metainfo.plist" |
| FONTINFO_FILENAME = "fontinfo.plist" |
| LIB_FILENAME = "lib.plist" |
| GROUPS_FILENAME = "groups.plist" |
| KERNING_FILENAME = "kerning.plist" |
| FEATURES_FILENAME = "features.fea" |
| LAYERCONTENTS_FILENAME = "layercontents.plist" |
| LAYERINFO_FILENAME = "layerinfo.plist" |
| |
| DEFAULT_LAYER_NAME = "public.default" |
| |
| supportedUFOFormatVersions = [1, 2, 3] |
| |
| |
| class UFOFileStructure(enum.Enum): |
| ZIP = "zip" |
| PACKAGE = "package" |
| |
| |
| # -------------- |
| # Shared Methods |
| # -------------- |
| |
| |
| class _UFOBaseIO(object): |
| |
| 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 datetimeAsTimestamp(dt) |
| |
| 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( |
| "'%s' could not be read on %s: %s" % (fileName, 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, basestring): |
| 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("unable to open '%s': %s" % (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 = unicode(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.readMetaInfo(validate=validate) |
| self._upConvertedKerningData = None |
| |
| # 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): |
| return self._formatVersion |
| |
| formatVersion = property(_get_formatVersion, doc="The format version of the UFO. This is determined by reading metainfo.plist during __init__.") |
| |
| 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, basestring): |
| raise UFOLibError(invalidFormatMessage) |
| elif not isinstance(glyphList, list): |
| raise UFOLibError(invalidFormatMessage) |
| for glyphName in glyphList: |
| if not isinstance(glyphName, basestring): |
| 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"]) |
| ) |
| # 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 unicode). |
| |
| 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. 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.") |
| formatVersion = data["formatVersion"] |
| if validate: |
| if not isinstance(formatVersion, int): |
| raise UFOLibError( |
| "formatVersion must be specified as an integer in '%s' on %s" |
| % (METAINFO_FILENAME, self.fs) |
| ) |
| if formatVersion not in supportedUFOFormatVersions: |
| raise UFOLibError( |
| "Unsupported UFO format (%d) in '%s' on %s" |
| % (formatVersion, METAINFO_FILENAME, self.fs) |
| ) |
| self._formatVersion = formatVersion |
| |
| # groups.plist |
| |
| def _readGroups(self): |
| return self._getPlist(GROUPS_FILENAME, {}) |
| |
| 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 < 3: |
| 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 >= 3: |
| 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 == 1: |
| 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 == 2: |
| for attr, dataValidationDict in list(fontInfoAttributesVersion2ValueData.items()): |
| value = infoDict.get(attr) |
| if value is None: |
| continue |
| infoDataToSet[attr] = value |
| infoDataToSet = _convertFontInfoDataVersion2ToVersion3(infoDataToSet) |
| # version 3 |
| elif self._formatVersion == 3: |
| for attr, dataValidationDict in list(fontInfoAttributesVersion3ValueData.items()): |
| value = infoDict.get(attr) |
| if value is None: |
| continue |
| infoDataToSet[attr] = value |
| # unsupported version |
| else: |
| raise NotImplementedError |
| # 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 < 3: |
| 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 unicode 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 < 3: |
| 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. |
| ``validateWrte`` 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( |
| "No '%s' directory for layer '%s'" % (directory, layerName) |
| ) |
| return GlyphSet( |
| glyphSubFS, |
| ufoFormatVersion=self._formatVersion, |
| validateRead=validateRead, |
| validateWrite=validateWrite, |
| ) |
| |
| 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 < 3: |
| 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("No data file named '%s' on %s" % (fileName, 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 < 3: |
| raise UFOLibError("Reading images is not allowed in UFO %d." % self._formatVersion) |
| 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("No image file named '%s' on %s" % (fileName, 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. |
| """ |
| |
| def __init__( |
| self, |
| path, |
| formatVersion=3, |
| fileCreator="com.github.fonttools.ufoLib", |
| structure=None, |
| validate=True, |
| ): |
| if formatVersion not in supportedUFOFormatVersions: |
| raise UFOLibError("Unsupported UFO format (%d)." % formatVersion) |
| |
| if hasattr(path, "__fspath__"): # support os.PathLike objects |
| path = path.__fspath__() |
| |
| if isinstance(path, basestring): |
| # 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 = unicode(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._getPlist(METAINFO_FILENAME) |
| previousFormatVersion = metaInfo.get("formatVersion") |
| try: |
| previousFormatVersion = int(previousFormatVersion) |
| except (ValueError, TypeError): |
| self.fs.close() |
| raise UFOLibError("The existing metainfo.plist is not properly formatted.") |
| if previousFormatVersion not in supportedUFOFormatVersions: |
| self.fs.close() |
| raise UFOLibError("Unsupported UFO format (%d)." % formatVersion) |
| # catch down conversion |
| if previousFormatVersion is not None and previousFormatVersion > formatVersion: |
| raise UFOLibError("The UFO located at this path is a higher version (%d) than the version (%d) that is trying to be written. This is not supported." % (previousFormatVersion, formatVersion)) |
| # handle the layer contents |
| self.layerContents = {} |
| if previousFormatVersion is not None and previousFormatVersion >= 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( |
| "unable to open '%s' on %s: %s" % (path, 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( |
| "'%s' does not exist on %s" % (path, 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 |
| ) |
| 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 >= 3: |
| 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 < 3 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 == 3: |
| if validate: |
| infoData = validateInfoVersion3Data(infoData) |
| elif self._formatVersion == 2: |
| infoData = _convertFontInfoDataVersion3ToVersion2(infoData) |
| if validate: |
| infoData = validateInfoVersion2Data(infoData) |
| elif self._formatVersion == 1: |
| infoData = _convertFontInfoDataVersion3ToVersion2(infoData) |
| if validate: |
| infoData = validateInfoVersion2Data(infoData) |
| infoData = _convertFontInfoDataVersion2ToVersion1(infoData) |
| # write file |
| 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], basestring): |
| raise UFOLibError(invalidFormatMessage) |
| if not isinstance(pair[1], basestring): |
| raise UFOLibError(invalidFormatMessage) |
| if not isinstance(value, numberTypes): |
| raise UFOLibError(invalidFormatMessage) |
| # down convert |
| if self._formatVersion < 3 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 == 1: |
| raise UFOLibError("features.fea is not allowed in UFO Format Version 1.") |
| if validate: |
| if not isinstance(features, basestring): |
| 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 < 3: |
| return |
| if layerOrder is not None: |
| newOrder = [] |
| for layerName in layerOrder: |
| if layerName is None: |
| layerName = DEFAULT_LAYER_NAME |
| else: |
| layerName = tounicode(layerName) |
| 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): |
| """ |
| 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. |
| """ |
| if validateRead is None: |
| validateRead = self._validate |
| if validateWrite is None: |
| validateWrite = self._validate |
| # only default can be written in < 3 |
| if self._formatVersion < 3 and (not defaultLayer or layerName is not None): |
| raise UFOLibError("Only the default layer can be writen in UFO %d." % self.formatVersion) |
| # 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 == 1: |
| return self._getGlyphSetFormatVersion1(validateRead, validateWrite, glyphNameToFileNameFunc=glyphNameToFileNameFunc) |
| elif self.formatVersion == 2: |
| return self._getGlyphSetFormatVersion2(validateRead, validateWrite, glyphNameToFileNameFunc=glyphNameToFileNameFunc) |
| elif self.formatVersion == 3: |
| return self._getGlyphSetFormatVersion3(validateRead, validateWrite, layerName=layerName, defaultLayer=defaultLayer, glyphNameToFileNameFunc=glyphNameToFileNameFunc) |
| else: |
| raise AssertionError(self.formatVersion) |
| |
| def _getGlyphSetFormatVersion1(self, validateRead, validateWrite, glyphNameToFileNameFunc=None): |
| from fontTools.ufoLib.glifLib import GlyphSet |
| |
| glyphSubFS = self.fs.makedir(DEFAULT_GLYPHS_DIRNAME, recreate=True), |
| return GlyphSet( |
| glyphSubFS, |
| glyphNameToFileNameFunc=glyphNameToFileNameFunc, |
| ufoFormatVersion=1, |
| validateRead=validateRead, |
| validateWrite=validateWrite, |
| ) |
| |
| def _getGlyphSetFormatVersion2(self, validateRead, validateWrite, glyphNameToFileNameFunc=None): |
| from fontTools.ufoLib.glifLib import GlyphSet |
| |
| glyphSubFS = self.fs.makedir(DEFAULT_GLYPHS_DIRNAME, recreate=True) |
| return GlyphSet( |
| glyphSubFS, |
| glyphNameToFileNameFunc=glyphNameToFileNameFunc, |
| ufoFormatVersion=2, |
| validateRead=validateRead, |
| validateWrite=validateWrite, |
| ) |
| |
| def _getGlyphSetFormatVersion3(self, validateRead, validateWrite, layerName=None, defaultLayer=True, glyphNameToFileNameFunc=None): |
| 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()} |
| if not isinstance(layerName, unicode): |
| try: |
| layerName = unicode(layerName) |
| except UnicodeDecodeError: |
| raise UFOLibError("The specified layer name is not a Unicode string.") |
| 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=3, |
| validateRead=validateRead, |
| validateWrite=validateWrite, |
| ) |
| |
| 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 < 3: |
| # 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 < 3: |
| # 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("%s/%s" % (DATA_DIRNAME, fsdecode(fileName)), data) |
| |
| def removeData(self, fileName): |
| """ |
| Remove the file named fileName from the data directory. |
| """ |
| self.removePath("%s/%s" % (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 < 3: |
| raise UFOLibError("Images are not allowed in UFO %d." % self._formatVersion) |
| fileName = fsdecode(fileName) |
| if validate: |
| valid, error = pngValidator(data=data) |
| if not valid: |
| raise UFOLibError(error) |
| self.writeBytesToPath("%s/%s" % (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 < 3: |
| raise UFOLibError("Images are not allowed in UFO %d." % self._formatVersion) |
| self.removePath("%s/%s" % (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 < 3: |
| raise UFOLibError("Images are not allowed in UFO %d." % self._formatVersion) |
| sourcePath = "%s/%s" % (IMAGES_DIRNAME, fsdecode(sourceFileName)) |
| destPath = "%s/%s" % (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(UFOWriter, self).close() |
| |
| |
| # just an alias, makes it more explicit |
| UFOReaderWriter = UFOWriter |
| |
| |
| # ---------------- |
| # Helper Functions |
| # ---------------- |
| |
| |
| def _sniffFileStructure(ufo_path): |
| """Return UFOFileStructure.ZIP if the UFO at path 'ufo_path' (basestring) |
| 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) |
| |
| # ---------------------- |
| # fontinfo.plist Support |
| # ---------------------- |
| |
| # Version Validators |
| |
| # There is no version 1 validator and there shouldn't be. |
| # 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 |
| |
| 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("Invalid value for attribute %s (%s)." % (attr, repr(value))) |
| 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 |
| |
| 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("Invalid value for attribute %s (%s)." % (attr, repr(value))) |
| else: |
| validInfoData[attr] = value |
| return validInfoData |
| |
| # Value Options |
| |
| fontInfoOpenTypeHeadFlagsOptions = list(range(0, 15)) |
| fontInfoOpenTypeOS2SelectionOptions = [1, 2, 3, 4, 7, 8, 9] |
| fontInfoOpenTypeOS2UnicodeRangesOptions = list(range(0, 128)) |
| fontInfoOpenTypeOS2CodePageRangesOptions = list(range(0, 64)) |
| fontInfoOpenTypeOS2TypeOptions = [0, 1, 2, 3, 8, 9] |
| |
| # Version Attribute Definitions |
| # This defines the attributes, types and, in some |
| # cases the possible values, that can exist is |
| # fontinfo.plist. |
| |
| fontInfoAttributesVersion1 = set([ |
| "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=basestring), |
| "styleName" : dict(type=basestring), |
| "styleMapFamilyName" : dict(type=basestring), |
| "styleMapStyleName" : dict(type=basestring, valueValidator=fontInfoStyleMapStyleNameValidator), |
| "versionMajor" : dict(type=int), |
| "versionMinor" : dict(type=int), |
| "year" : dict(type=int), |
| "copyright" : dict(type=basestring), |
| "trademark" : dict(type=basestring), |
| "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=basestring), |
| "openTypeHeadCreated" : dict(type=basestring, 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=basestring), |
| "openTypeNameDesignerURL" : dict(type=basestring), |
| "openTypeNameManufacturer" : dict(type=basestring), |
| "openTypeNameManufacturerURL" : dict(type=basestring), |
| "openTypeNameLicense" : dict(type=basestring), |
| "openTypeNameLicenseURL" : dict(type=basestring), |
| "openTypeNameVersion" : dict(type=basestring), |
| "openTypeNameUniqueID" : dict(type=basestring), |
| "openTypeNameDescription" : dict(type=basestring), |
| "openTypeNamePreferredFamilyName" : dict(type=basestring), |
| "openTypeNamePreferredSubfamilyName" : dict(type=basestring), |
| "openTypeNameCompatibleFullName" : dict(type=basestring), |
| "openTypeNameSampleText" : dict(type=basestring), |
| "openTypeNameWWSFamilyName" : dict(type=basestring), |
| "openTypeNameWWSSubfamilyName" : dict(type=basestring), |
| "openTypeOS2WidthClass" : dict(type=int, valueValidator=fontInfoOpenTypeOS2WidthClassValidator), |
| "openTypeOS2WeightClass" : dict(type=int, valueValidator=fontInfoOpenTypeOS2WeightClassValidator), |
| "openTypeOS2Selection" : dict(type="integerList", valueValidator=genericIntListValidator, valueOptions=fontInfoOpenTypeOS2SelectionOptions), |
| "openTypeOS2VendorID" : dict(type=basestring), |
| "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=basestring), |
| "postscriptFullName" : dict(type=basestring), |
| "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=basestring), |
| "postscriptDefaultCharacter" : dict(type=basestring), |
| "postscriptWindowsCharacterSet" : dict(type=int, valueValidator=fontInfoPostscriptWindowsCharacterSetValidator), |
| "macintoshFONDFamilyID" : dict(type=int), |
| "macintoshFONDName" : dict(type=basestring), |
| } |
| 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) |
| }) |
| 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 |
| |
| for attr, dataDict in list(fontInfoAttributesVersion3ValueData.items()): |
| 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 |
| |
| 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" |
| } |
| fontInfoAttributesVersion2To1 = _flipDict(fontInfoAttributesVersion1To2) |
| deprecatedFontInfoAttributesVersion2 = set(fontInfoAttributesVersion1To2.keys()) |
| |
| _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 |
| } |
| _widthName2To1 = _flipDict(_widthName1To2) |
| # FontLab's default width value is "Normal". |
| # Many format version 1 UFOs will have this. |
| _widthName1To2["Normal"] = 5 |
| # FontLab has an "All" width value. In UFO 1 |
| # move this up to "Normal". |
| _widthName1To2["All"] = 5 |
| # "medium" appears in a lot of UFO 1 files. |
| _widthName1To2["medium"] = 5 |
| # "Medium" appears in a lot of UFO 1 files. |
| _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 |
| } |
| _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("Cannot convert value (%s) for attribute %s." % (repr(value), attr)) |
| value = v |
| elif attr == "widthName": |
| v = _widthName1To2.get(value) |
| if v is None: |
| raise UFOLibError("Cannot convert value (%s) for attribute %s." % (repr(value), attr)) |
| value = v |
| elif attr == "msCharSet": |
| v = _msCharSet1To2.get(value) |
| if v is None: |
| raise UFOLibError("Cannot convert value (%s) for attribute %s." % (repr(value), 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 |
| |
| 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("Cannot convert value (%s) for attribute %s." % (repr(value), 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("Cannot convert value (%s) for attribute %s." % (repr(value), newAttr)) |
| # store |
| converted[newAttr] = newValue |
| return converted |
| |
| # 2 <-> 3 |
| |
| _ufo2To3NonNegativeInt = set(( |
| "versionMinor", |
| "openTypeHeadLowestRecPPEM", |
| "openTypeOS2WinAscent", |
| "openTypeOS2WinDescent" |
| )) |
| _ufo2To3NonNegativeIntOrFloat = set(( |
| "unitsPerEm" |
| )) |
| _ufo2To3FloatToInt = set((( |
| "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: |
| v = int(round(value)) |
| except (ValueError, TypeError): |
| raise UFOLibError("Could not convert value for %s." % attr) |
| if v != value: |
| value = v |
| if attr in _ufo2To3NonNegativeInt: |
| try: |
| v = int(abs(value)) |
| except (ValueError, TypeError): |
| raise UFOLibError("Could not convert value for %s." % attr) |
| if v != value: |
| value = v |
| 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 |
| |
| 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 |
| |
| def _convertFontInfoDataVersion2ToVersion3(data): |
| 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() |