Spaces:
Runtime error
Runtime error
import os | |
from copy import deepcopy | |
from os import fsdecode | |
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 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 numberTypes, _VersionTupleEnumMixin | |
""" | |
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" | |
class UFOFormatVersion(tuple, _VersionTupleEnumMixin, enum.Enum): | |
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 | |
# https://github.com/fonttools/fonttools/pull/2655 | |
UFOFormatVersion.__str__ = _VersionTupleEnumMixin.__str__ | |
class UFOFileStructure(enum.Enum): | |
ZIP = "zip" | |
PACKAGE = "package" | |
# -------------- | |
# Shared Methods | |
# -------------- | |
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) | |
) | |
# ---------- | |
# 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", | |
) | |
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() | |
# 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' (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) | |
# ---------------------- | |
# 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(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 | |
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 | |
# 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 = { | |
"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), | |
} | |
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( | |
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 | |
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 | |
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 | |
# 2 <-> 3 | |
_ufo2To3NonNegativeInt = { | |
"versionMinor", | |
"openTypeHeadLowestRecPPEM", | |
"openTypeOS2WinAscent", | |
"openTypeOS2WinDescent", | |
} | |
_ufo2To3NonNegativeIntOrFloat = { | |
"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", | |
} | |
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 | |
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() | |