|
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__) |
|
|
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
|
|
UFOFormatVersion.__str__ = _VersionTupleEnumMixin.__str__ |
|
|
|
|
|
class UFOFileStructure(enum.Enum): |
|
ZIP = "zip" |
|
PACKAGE = "package" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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: |
|
|
|
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) |
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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__"): |
|
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: |
|
|
|
|
|
rootDirs = [ |
|
p.name |
|
for p in parentFS.scandir("/") |
|
|
|
if p.is_dir and p.name != "__MACOSX" |
|
] |
|
if len(rootDirs) == 1: |
|
|
|
|
|
self.fs = parentFS.opendir( |
|
rootDirs[0], factory=fs.subfs.ClosingSubFS |
|
) |
|
else: |
|
raise UFOLibError( |
|
"Expected exactly 1 root directory, found %d" % len(rootDirs) |
|
) |
|
else: |
|
|
|
self.fs = parentFS |
|
|
|
|
|
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: |
|
|
|
path = str(filesystem) |
|
|
|
|
|
self._shouldClose = False |
|
|
|
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 |
|
|
|
|
|
|
|
def _get_path(self): |
|
import warnings |
|
|
|
warnings.warn( |
|
"The 'path' attribute is deprecated; use the 'fs' attribute instead", |
|
DeprecationWarning, |
|
stacklevel=2, |
|
) |
|
return self._path |
|
|
|
path = property(_get_path, doc="The path of the UFO (DEPRECATED).") |
|
|
|
def _get_formatVersion(self): |
|
import warnings |
|
|
|
warnings.warn( |
|
"The 'formatVersion' attribute is deprecated; use the 'formatVersionTuple'", |
|
DeprecationWarning, |
|
stacklevel=2, |
|
) |
|
return self._formatVersion.major |
|
|
|
formatVersion = property( |
|
_get_formatVersion, |
|
doc="The (major) format version of the UFO. DEPRECATED: Use formatVersionTuple", |
|
) |
|
|
|
@property |
|
def formatVersionTuple(self): |
|
"""The (major, minor) format version of the UFO. |
|
This is determined by reading metainfo.plist during __init__. |
|
""" |
|
return self._formatVersion |
|
|
|
def _get_fileStructure(self): |
|
return self._fileStructure |
|
|
|
fileStructure = property( |
|
_get_fileStructure, |
|
doc=( |
|
"The file structure of the UFO: " |
|
"either UFOFileStructure.ZIP or UFOFileStructure.PACKAGE" |
|
), |
|
) |
|
|
|
|
|
|
|
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, |
|
) |
|
|
|
kerning, groups, conversionMaps = convertUFO1OrUFO2KerningToUFO3Kerning( |
|
self._upConvertedKerningData["originalKerning"], |
|
deepcopy(self._upConvertedKerningData["originalGroups"]), |
|
self.getGlyphSet(), |
|
) |
|
|
|
self._upConvertedKerningData["kerning"] = kerning |
|
self._upConvertedKerningData["groups"] = groups |
|
self._upConvertedKerningData["groupRenameMaps"] = conversionMaps |
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
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"] |
|
|
|
|
|
|
|
def _readGroups(self): |
|
groups = self._getPlist(GROUPS_FILENAME, {}) |
|
|
|
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 |
|
|
|
if self._formatVersion < UFOFormatVersion.FORMAT_3_0: |
|
self._upConvertKerning(validate) |
|
groups = self._upConvertedKerningData["groups"] |
|
|
|
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={}) |
|
|
|
|
|
self.readGroups(validate=validate) |
|
return self._upConvertedKerningData["groupRenameMaps"] |
|
|
|
|
|
|
|
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 = {} |
|
|
|
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) |
|
|
|
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) |
|
|
|
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 |
|
|
|
else: |
|
raise NotImplementedError(self._formatVersion) |
|
|
|
if validate: |
|
infoDataToSet = validateInfoVersion3Data(infoDataToSet) |
|
|
|
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 |
|
) |
|
|
|
|
|
|
|
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 |
|
|
|
if self._formatVersion < UFOFormatVersion.FORMAT_3_0: |
|
self._upConvertKerning(validate) |
|
kerningNested = self._upConvertedKerningData["kerning"] |
|
|
|
else: |
|
kerningNested = self._readKerning() |
|
if validate: |
|
valid, message = kerningValidator(kerningNested) |
|
if not valid: |
|
raise UFOLibError(message) |
|
|
|
kerning = {} |
|
for left in kerningNested: |
|
for right in kerningNested[left]: |
|
value = kerningNested[left][right] |
|
kerning[left, right] = value |
|
return kerning |
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
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 "" |
|
|
|
|
|
|
|
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 |
|
|
|
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 |
|
|
|
|
|
|
|
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: |
|
|
|
|
|
|
|
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: |
|
|
|
|
|
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: |
|
|
|
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: |
|
|
|
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() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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__"): |
|
path = path.__fspath__() |
|
|
|
if isinstance(path, str): |
|
|
|
path = os.path.normpath(path) |
|
havePreviousFile = os.path.exists(path) |
|
if havePreviousFile: |
|
|
|
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 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: |
|
|
|
|
|
|
|
parentFS = fs.tempfs.TempFS() |
|
with fs.zipfs.ZipFS(path, encoding="utf-8") as origFS: |
|
fs.copy.copy_fs(origFS, parentFS) |
|
|
|
|
|
|
|
rootDirs = [ |
|
p.name |
|
for p in parentFS.scandir("/") |
|
|
|
if p.is_dir and p.name != "__MACOSX" |
|
] |
|
if len(rootDirs) != 1: |
|
raise UFOLibError( |
|
"Expected exactly 1 root directory, found %d" |
|
% len(rootDirs) |
|
) |
|
else: |
|
|
|
|
|
self.fs = parentFS.opendir( |
|
rootDirs[0], factory=fs.subfs.ClosingSubFS |
|
) |
|
else: |
|
|
|
|
|
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: |
|
|
|
path = str(filesystem) |
|
|
|
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 |
|
|
|
self._havePreviousFile = filesystem.exists(METAINFO_FILENAME) |
|
|
|
self._shouldClose = False |
|
else: |
|
raise TypeError( |
|
"Expected a path string or fs object, found %s" % type(path).__name__ |
|
) |
|
|
|
|
|
self._path = fsdecode(path) |
|
self._formatVersion = formatVersion |
|
self._fileCreator = fileCreator |
|
self._downConversionKerningData = None |
|
self._validate = validate |
|
|
|
|
|
previousFormatVersion = None |
|
if self._havePreviousFile: |
|
metaInfo = self._readMetaInfo(validate=validate) |
|
previousFormatVersion = metaInfo["formatVersionTuple"] |
|
|
|
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." |
|
) |
|
|
|
self.layerContents = {} |
|
if previousFormatVersion is not None and previousFormatVersion.major >= 3: |
|
|
|
self.layerContents = OrderedDict(self._readLayerContents(validate)) |
|
else: |
|
|
|
|
|
if self.fs.exists(DEFAULT_GLYPHS_DIRNAME): |
|
self.layerContents = {DEFAULT_LAYER_NAME: DEFAULT_GLYPHS_DIRNAME} |
|
|
|
self._writeMetaInfo() |
|
|
|
|
|
|
|
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__.", |
|
) |
|
|
|
|
|
|
|
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) |
|
|
|
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": |
|
|
|
|
|
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) |
|
|
|
|
|
removeFileForPath = removePath |
|
|
|
|
|
|
|
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: |
|
|
|
os.utime(path, None) |
|
except OSError as e: |
|
logger.warning("Failed to set modified time: %s", e) |
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
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 |
|
|
|
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 |
|
|
|
if validate: |
|
valid, message = groupsValidator(groups) |
|
if not valid: |
|
raise UFOLibError(message) |
|
|
|
if ( |
|
self._formatVersion < UFOFormatVersion.FORMAT_3_0 |
|
and self._downConversionKerningData is not None |
|
): |
|
remap = self._downConversionKerningData["groupRenameMap"] |
|
remappedGroups = {} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
|
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) |
|
|
|
|
|
|
|
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 |
|
|
|
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 |
|
|
|
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) |
|
|
|
if infoData: |
|
self._writePlist(FONTINFO_FILENAME, infoData) |
|
|
|
|
|
|
|
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 |
|
|
|
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) |
|
|
|
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 |
|
|
|
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) |
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
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) |
|
|
|
|
|
|
|
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 |
|
|
|
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}." |
|
) |
|
|
|
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.") |
|
|
|
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 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." |
|
) |
|
|
|
if layerName in self.layerContents: |
|
directory = self.layerContents[layerName] |
|
|
|
else: |
|
if defaultLayer: |
|
directory = DEFAULT_GLYPHS_DIRNAME |
|
else: |
|
|
|
|
|
existing = {d.lower() for d in self.layerContents.values()} |
|
directory = userNameToFileName( |
|
layerName, existing=existing, prefix="glyphs." |
|
) |
|
|
|
glyphSubFS = self.fs.makedir(directory, recreate=True) |
|
|
|
self.layerContents[layerName] = directory |
|
|
|
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: |
|
|
|
|
|
return |
|
|
|
|
|
if layerName == newLayerName: |
|
|
|
if ( |
|
self.layerContents[layerName] != DEFAULT_GLYPHS_DIRNAME |
|
and not defaultLayer |
|
): |
|
return |
|
|
|
if self.layerContents[layerName] == DEFAULT_GLYPHS_DIRNAME and defaultLayer: |
|
return |
|
else: |
|
|
|
if newLayerName is None: |
|
newLayerName = DEFAULT_LAYER_NAME |
|
if newLayerName in self.layerContents: |
|
raise UFOLibError("A layer named %s already exists." % newLayerName) |
|
|
|
if defaultLayer and DEFAULT_GLYPHS_DIRNAME in self.layerContents.values(): |
|
raise UFOLibError("A default layer already exists.") |
|
|
|
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." |
|
) |
|
|
|
del self.layerContents[layerName] |
|
self.layerContents[newLayerName] = newDirectory |
|
|
|
self.fs.movedir(oldDirectory, newDirectory, create=True) |
|
|
|
def deleteGlyphSet(self, layerName): |
|
""" |
|
Remove the glyph set matching layerName. |
|
""" |
|
if self._formatVersion < UFOFormatVersion.FORMAT_3_0: |
|
|
|
|
|
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)}") |
|
|
|
|
|
|
|
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): |
|
""" |
|
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: |
|
|
|
|
|
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() |
|
|
|
|
|
|
|
UFOReaderWriter = UFOWriter |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sniffFileStructure(ufo_path): |
|
"""Return UFOFileStructure.ZIP if the UFO at path 'ufo_path' (str) |
|
is a zip file, else return UFOFileStructure.PACKAGE if 'ufo_path' is a |
|
directory. |
|
Raise UFOLibError if it is a file with unknown structure, or if the path |
|
does not exist. |
|
""" |
|
if zipfile.is_zipfile(ufo_path): |
|
return UFOFileStructure.ZIP |
|
elif os.path.isdir(ufo_path): |
|
return UFOFileStructure.PACKAGE |
|
elif os.path.isfile(ufo_path): |
|
raise UFOLibError( |
|
"The specified UFO does not have a known structure: '%s'" % ufo_path |
|
) |
|
else: |
|
raise UFOLibError("No such file or directory: '%s'" % ufo_path) |
|
|
|
|
|
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) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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") |
|
|
|
if valueOptions is not None: |
|
isValidValue = validator(value, valueOptions) |
|
|
|
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") |
|
|
|
if valueOptions is not None: |
|
isValidValue = validator(value, valueOptions) |
|
|
|
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 |
|
|
|
|
|
|
|
|
|
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] |
|
|
|
|
|
|
|
|
|
|
|
|
|
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()) |
|
|
|
|
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
|
|
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", |
|
|
|
"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) |
|
|
|
_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) |
|
|
|
|
|
_widthName1To2["Normal"] = 5 |
|
|
|
|
|
_widthName1To2["All"] = 5 |
|
|
|
_widthName1To2["medium"] = 5 |
|
|
|
_widthName1To2["Medium"] = 5 |
|
|
|
_msCharSet1To2 = { |
|
0: 1, |
|
1: 2, |
|
2: 3, |
|
77: 4, |
|
128: 5, |
|
129: 6, |
|
130: 7, |
|
134: 8, |
|
136: 9, |
|
161: 10, |
|
162: 11, |
|
163: 12, |
|
177: 13, |
|
178: 14, |
|
186: 15, |
|
200: 16, |
|
204: 17, |
|
222: 18, |
|
238: 19, |
|
255: 20, |
|
} |
|
_msCharSet2To1 = _flipDict(_msCharSet1To2) |
|
|
|
|
|
|
|
|
|
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. |
|
""" |
|
|
|
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()): |
|
|
|
|
|
|
|
if attr == "weightValue" and value == -1: |
|
continue |
|
newAttr, newValue = convertFontInfoValueForAttributeFromVersion1ToVersion2( |
|
attr, value |
|
) |
|
|
|
if newAttr not in fontInfoAttributesVersion2: |
|
continue |
|
|
|
if value is None: |
|
raise UFOLibError( |
|
f"Cannot convert value ({value!r}) for attribute {newAttr}." |
|
) |
|
|
|
converted[newAttr] = newValue |
|
return converted |
|
|
|
|
|
def _convertFontInfoDataVersion2ToVersion1(data): |
|
converted = {} |
|
for attr, value in list(data.items()): |
|
newAttr, newValue = convertFontInfoValueForAttributeFromVersion2ToVersion1( |
|
attr, value |
|
) |
|
|
|
if newAttr not in fontInfoAttributesVersion1: |
|
continue |
|
|
|
if value is None: |
|
raise UFOLibError( |
|
f"Cannot convert value ({value!r}) for attribute {newAttr}." |
|
) |
|
|
|
converted[newAttr] = newValue |
|
return converted |
|
|
|
|
|
|
|
|
|
_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() |
|
|