2463 lines
92 KiB
Python
2463 lines
92 KiB
Python
|
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",
|
||
|
)
|
||
|
|
||
|
@property
|
||
|
def formatVersionTuple(self):
|
||
|
"""The (major, minor) format version of the UFO.
|
||
|
This is determined by reading metainfo.plist during __init__.
|
||
|
"""
|
||
|
return self._formatVersion
|
||
|
|
||
|
def _get_fileStructure(self):
|
||
|
return self._fileStructure
|
||
|
|
||
|
fileStructure = property(
|
||
|
_get_fileStructure,
|
||
|
doc=(
|
||
|
"The file structure of the UFO: "
|
||
|
"either UFOFileStructure.ZIP or UFOFileStructure.PACKAGE"
|
||
|
),
|
||
|
)
|
||
|
|
||
|
# up conversion
|
||
|
|
||
|
def _upConvertKerning(self, validate):
|
||
|
"""
|
||
|
Up convert kerning and groups in UFO 1 and 2.
|
||
|
The data will be held internally until each bit of data
|
||
|
has been retrieved. The conversion of both must be done
|
||
|
at once, so the raw data is cached and an error is raised
|
||
|
if one bit of data becomes obsolete before it is called.
|
||
|
|
||
|
``validate`` will validate the data.
|
||
|
"""
|
||
|
if self._upConvertedKerningData:
|
||
|
testKerning = self._readKerning()
|
||
|
if testKerning != self._upConvertedKerningData["originalKerning"]:
|
||
|
raise UFOLibError(
|
||
|
"The data in kerning.plist has been modified since it was converted to UFO 3 format."
|
||
|
)
|
||
|
testGroups = self._readGroups()
|
||
|
if testGroups != self._upConvertedKerningData["originalGroups"]:
|
||
|
raise UFOLibError(
|
||
|
"The data in groups.plist has been modified since it was converted to UFO 3 format."
|
||
|
)
|
||
|
else:
|
||
|
groups = self._readGroups()
|
||
|
if validate:
|
||
|
invalidFormatMessage = "groups.plist is not properly formatted."
|
||
|
if not isinstance(groups, dict):
|
||
|
raise UFOLibError(invalidFormatMessage)
|
||
|
for groupName, glyphList in groups.items():
|
||
|
if not isinstance(groupName, str):
|
||
|
raise UFOLibError(invalidFormatMessage)
|
||
|
elif not isinstance(glyphList, list):
|
||
|
raise UFOLibError(invalidFormatMessage)
|
||
|
for glyphName in glyphList:
|
||
|
if not isinstance(glyphName, str):
|
||
|
raise UFOLibError(invalidFormatMessage)
|
||
|
self._upConvertedKerningData = dict(
|
||
|
kerning={},
|
||
|
originalKerning=self._readKerning(),
|
||
|
groups={},
|
||
|
originalGroups=groups,
|
||
|
)
|
||
|
# convert kerning and groups
|
||
|
kerning, groups, conversionMaps = convertUFO1OrUFO2KerningToUFO3Kerning(
|
||
|
self._upConvertedKerningData["originalKerning"],
|
||
|
deepcopy(self._upConvertedKerningData["originalGroups"]),
|
||
|
self.getGlyphSet(),
|
||
|
)
|
||
|
# store
|
||
|
self._upConvertedKerningData["kerning"] = kerning
|
||
|
self._upConvertedKerningData["groups"] = groups
|
||
|
self._upConvertedKerningData["groupRenameMaps"] = conversionMaps
|
||
|
|
||
|
# support methods
|
||
|
|
||
|
def readBytesFromPath(self, path):
|
||
|
"""
|
||
|
Returns the bytes in the file at the given path.
|
||
|
The path must be relative to the UFO's filesystem root.
|
||
|
Returns None if the file does not exist.
|
||
|
"""
|
||
|
try:
|
||
|
return self.fs.readbytes(fsdecode(path))
|
||
|
except fs.errors.ResourceNotFound:
|
||
|
return None
|
||
|
|
||
|
def getReadFileForPath(self, path, encoding=None):
|
||
|
"""
|
||
|
Returns a file (or file-like) object for the file at the given path.
|
||
|
The path must be relative to the UFO path.
|
||
|
Returns None if the file does not exist.
|
||
|
By default the file is opened in binary mode (reads bytes).
|
||
|
If encoding is passed, the file is opened in text mode (reads str).
|
||
|
|
||
|
Note: The caller is responsible for closing the open file.
|
||
|
"""
|
||
|
path = fsdecode(path)
|
||
|
try:
|
||
|
if encoding is None:
|
||
|
return self.fs.openbin(path)
|
||
|
else:
|
||
|
return self.fs.open(path, mode="r", encoding=encoding)
|
||
|
except fs.errors.ResourceNotFound:
|
||
|
return None
|
||
|
|
||
|
# metainfo.plist
|
||
|
|
||
|
def _readMetaInfo(self, validate=None):
|
||
|
"""
|
||
|
Read metainfo.plist and return raw data. Only used for internal operations.
|
||
|
|
||
|
``validate`` will validate the read data, by default it is set
|
||
|
to the class's validate value, can be overridden.
|
||
|
"""
|
||
|
if validate is None:
|
||
|
validate = self._validate
|
||
|
data = self._getPlist(METAINFO_FILENAME)
|
||
|
if validate and not isinstance(data, dict):
|
||
|
raise UFOLibError("metainfo.plist is not properly formatted.")
|
||
|
try:
|
||
|
formatVersionMajor = data["formatVersion"]
|
||
|
except KeyError:
|
||
|
raise UFOLibError(
|
||
|
f"Missing required formatVersion in '{METAINFO_FILENAME}' on {self.fs}"
|
||
|
)
|
||
|
formatVersionMinor = data.setdefault("formatVersionMinor", 0)
|
||
|
|
||
|
try:
|
||
|
formatVersion = UFOFormatVersion((formatVersionMajor, formatVersionMinor))
|
||
|
except ValueError as e:
|
||
|
unsupportedMsg = (
|
||
|
f"Unsupported UFO format ({formatVersionMajor}.{formatVersionMinor}) "
|
||
|
f"in '{METAINFO_FILENAME}' on {self.fs}"
|
||
|
)
|
||
|
if validate:
|
||
|
from fontTools.ufoLib.errors import UnsupportedUFOFormat
|
||
|
|
||
|
raise UnsupportedUFOFormat(unsupportedMsg) from e
|
||
|
|
||
|
formatVersion = UFOFormatVersion.default()
|
||
|
logger.warning(
|
||
|
"%s. Assuming the latest supported version (%s). "
|
||
|
"Some data may be skipped or parsed incorrectly",
|
||
|
unsupportedMsg,
|
||
|
formatVersion,
|
||
|
)
|
||
|
data["formatVersionTuple"] = formatVersion
|
||
|
return data
|
||
|
|
||
|
def readMetaInfo(self, validate=None):
|
||
|
"""
|
||
|
Read metainfo.plist and set formatVersion. Only used for internal operations.
|
||
|
|
||
|
``validate`` will validate the read data, by default it is set
|
||
|
to the class's validate value, can be overridden.
|
||
|
"""
|
||
|
data = self._readMetaInfo(validate=validate)
|
||
|
self._formatVersion = data["formatVersionTuple"]
|
||
|
|
||
|
# groups.plist
|
||
|
|
||
|
def _readGroups(self):
|
||
|
groups = self._getPlist(GROUPS_FILENAME, {})
|
||
|
# remove any duplicate glyphs in a kerning group
|
||
|
for groupName, glyphList in groups.items():
|
||
|
if groupName.startswith(("public.kern1.", "public.kern2.")):
|
||
|
groups[groupName] = list(OrderedDict.fromkeys(glyphList))
|
||
|
return groups
|
||
|
|
||
|
def readGroups(self, validate=None):
|
||
|
"""
|
||
|
Read groups.plist. Returns a dict.
|
||
|
``validate`` will validate the read data, by default it is set to the
|
||
|
class's validate value, can be overridden.
|
||
|
"""
|
||
|
if validate is None:
|
||
|
validate = self._validate
|
||
|
# handle up conversion
|
||
|
if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
|
||
|
self._upConvertKerning(validate)
|
||
|
groups = self._upConvertedKerningData["groups"]
|
||
|
# normal
|
||
|
else:
|
||
|
groups = self._readGroups()
|
||
|
if validate:
|
||
|
valid, message = groupsValidator(groups)
|
||
|
if not valid:
|
||
|
raise UFOLibError(message)
|
||
|
return groups
|
||
|
|
||
|
def getKerningGroupConversionRenameMaps(self, validate=None):
|
||
|
"""
|
||
|
Get maps defining the renaming that was done during any
|
||
|
needed kerning group conversion. This method returns a
|
||
|
dictionary of this form::
|
||
|
|
||
|
{
|
||
|
"side1" : {"old group name" : "new group name"},
|
||
|
"side2" : {"old group name" : "new group name"}
|
||
|
}
|
||
|
|
||
|
When no conversion has been performed, the side1 and side2
|
||
|
dictionaries will be empty.
|
||
|
|
||
|
``validate`` will validate the groups, by default it is set to the
|
||
|
class's validate value, can be overridden.
|
||
|
"""
|
||
|
if validate is None:
|
||
|
validate = self._validate
|
||
|
if self._formatVersion >= UFOFormatVersion.FORMAT_3_0:
|
||
|
return dict(side1={}, side2={})
|
||
|
# use the public group reader to force the load and
|
||
|
# conversion of the data if it hasn't happened yet.
|
||
|
self.readGroups(validate=validate)
|
||
|
return self._upConvertedKerningData["groupRenameMaps"]
|
||
|
|
||
|
# fontinfo.plist
|
||
|
|
||
|
def _readInfo(self, validate):
|
||
|
data = self._getPlist(FONTINFO_FILENAME, {})
|
||
|
if validate and not isinstance(data, dict):
|
||
|
raise UFOLibError("fontinfo.plist is not properly formatted.")
|
||
|
return data
|
||
|
|
||
|
def readInfo(self, info, validate=None):
|
||
|
"""
|
||
|
Read fontinfo.plist. It requires an object that allows
|
||
|
setting attributes with names that follow the fontinfo.plist
|
||
|
version 3 specification. This will write the attributes
|
||
|
defined in the file into the object.
|
||
|
|
||
|
``validate`` will validate the read data, by default it is set to the
|
||
|
class's validate value, can be overridden.
|
||
|
"""
|
||
|
if validate is None:
|
||
|
validate = self._validate
|
||
|
infoDict = self._readInfo(validate)
|
||
|
infoDataToSet = {}
|
||
|
# version 1
|
||
|
if self._formatVersion == UFOFormatVersion.FORMAT_1_0:
|
||
|
for attr in fontInfoAttributesVersion1:
|
||
|
value = infoDict.get(attr)
|
||
|
if value is not None:
|
||
|
infoDataToSet[attr] = value
|
||
|
infoDataToSet = _convertFontInfoDataVersion1ToVersion2(infoDataToSet)
|
||
|
infoDataToSet = _convertFontInfoDataVersion2ToVersion3(infoDataToSet)
|
||
|
# version 2
|
||
|
elif self._formatVersion == UFOFormatVersion.FORMAT_2_0:
|
||
|
for attr, dataValidationDict in list(
|
||
|
fontInfoAttributesVersion2ValueData.items()
|
||
|
):
|
||
|
value = infoDict.get(attr)
|
||
|
if value is None:
|
||
|
continue
|
||
|
infoDataToSet[attr] = value
|
||
|
infoDataToSet = _convertFontInfoDataVersion2ToVersion3(infoDataToSet)
|
||
|
# version 3.x
|
||
|
elif self._formatVersion.major == UFOFormatVersion.FORMAT_3_0.major:
|
||
|
for attr, dataValidationDict in list(
|
||
|
fontInfoAttributesVersion3ValueData.items()
|
||
|
):
|
||
|
value = infoDict.get(attr)
|
||
|
if value is None:
|
||
|
continue
|
||
|
infoDataToSet[attr] = value
|
||
|
# unsupported version
|
||
|
else:
|
||
|
raise NotImplementedError(self._formatVersion)
|
||
|
# validate data
|
||
|
if validate:
|
||
|
infoDataToSet = validateInfoVersion3Data(infoDataToSet)
|
||
|
# populate the object
|
||
|
for attr, value in list(infoDataToSet.items()):
|
||
|
try:
|
||
|
setattr(info, attr, value)
|
||
|
except AttributeError:
|
||
|
raise UFOLibError(
|
||
|
"The supplied info object does not support setting a necessary attribute (%s)."
|
||
|
% attr
|
||
|
)
|
||
|
|
||
|
# kerning.plist
|
||
|
|
||
|
def _readKerning(self):
|
||
|
data = self._getPlist(KERNING_FILENAME, {})
|
||
|
return data
|
||
|
|
||
|
def readKerning(self, validate=None):
|
||
|
"""
|
||
|
Read kerning.plist. Returns a dict.
|
||
|
|
||
|
``validate`` will validate the kerning data, by default it is set to the
|
||
|
class's validate value, can be overridden.
|
||
|
"""
|
||
|
if validate is None:
|
||
|
validate = self._validate
|
||
|
# handle up conversion
|
||
|
if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
|
||
|
self._upConvertKerning(validate)
|
||
|
kerningNested = self._upConvertedKerningData["kerning"]
|
||
|
# normal
|
||
|
else:
|
||
|
kerningNested = self._readKerning()
|
||
|
if validate:
|
||
|
valid, message = kerningValidator(kerningNested)
|
||
|
if not valid:
|
||
|
raise UFOLibError(message)
|
||
|
# flatten
|
||
|
kerning = {}
|
||
|
for left in kerningNested:
|
||
|
for right in kerningNested[left]:
|
||
|
value = kerningNested[left][right]
|
||
|
kerning[left, right] = value
|
||
|
return kerning
|
||
|
|
||
|
# lib.plist
|
||
|
|
||
|
def readLib(self, validate=None):
|
||
|
"""
|
||
|
Read lib.plist. Returns a dict.
|
||
|
|
||
|
``validate`` will validate the data, by default it is set to the
|
||
|
class's validate value, can be overridden.
|
||
|
"""
|
||
|
if validate is None:
|
||
|
validate = self._validate
|
||
|
data = self._getPlist(LIB_FILENAME, {})
|
||
|
if validate:
|
||
|
valid, message = fontLibValidator(data)
|
||
|
if not valid:
|
||
|
raise UFOLibError(message)
|
||
|
return data
|
||
|
|
||
|
# features.fea
|
||
|
|
||
|
def readFeatures(self):
|
||
|
"""
|
||
|
Read features.fea. Return a string.
|
||
|
The returned string is empty if the file is missing.
|
||
|
"""
|
||
|
try:
|
||
|
with self.fs.open(FEATURES_FILENAME, "r", encoding="utf-8") as f:
|
||
|
return f.read()
|
||
|
except fs.errors.ResourceNotFound:
|
||
|
return ""
|
||
|
|
||
|
# glyph sets & layers
|
||
|
|
||
|
def _readLayerContents(self, validate):
|
||
|
"""
|
||
|
Rebuild the layer contents list by checking what glyphsets
|
||
|
are available on disk.
|
||
|
|
||
|
``validate`` will validate the layer contents.
|
||
|
"""
|
||
|
if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
|
||
|
return [(DEFAULT_LAYER_NAME, DEFAULT_GLYPHS_DIRNAME)]
|
||
|
contents = self._getPlist(LAYERCONTENTS_FILENAME)
|
||
|
if validate:
|
||
|
valid, error = layerContentsValidator(contents, self.fs)
|
||
|
if not valid:
|
||
|
raise UFOLibError(error)
|
||
|
return contents
|
||
|
|
||
|
def getLayerNames(self, validate=None):
|
||
|
"""
|
||
|
Get the ordered layer names from layercontents.plist.
|
||
|
|
||
|
``validate`` will validate the data, by default it is set to the
|
||
|
class's validate value, can be overridden.
|
||
|
"""
|
||
|
if validate is None:
|
||
|
validate = self._validate
|
||
|
layerContents = self._readLayerContents(validate)
|
||
|
layerNames = [layerName for layerName, directoryName in layerContents]
|
||
|
return layerNames
|
||
|
|
||
|
def getDefaultLayerName(self, validate=None):
|
||
|
"""
|
||
|
Get the default layer name from layercontents.plist.
|
||
|
|
||
|
``validate`` will validate the data, by default it is set to the
|
||
|
class's validate value, can be overridden.
|
||
|
"""
|
||
|
if validate is None:
|
||
|
validate = self._validate
|
||
|
layerContents = self._readLayerContents(validate)
|
||
|
for layerName, layerDirectory in layerContents:
|
||
|
if layerDirectory == DEFAULT_GLYPHS_DIRNAME:
|
||
|
return layerName
|
||
|
# this will already have been raised during __init__
|
||
|
raise UFOLibError("The default layer is not defined in layercontents.plist.")
|
||
|
|
||
|
def getGlyphSet(self, layerName=None, validateRead=None, validateWrite=None):
|
||
|
"""
|
||
|
Return the GlyphSet associated with the
|
||
|
glyphs directory mapped to layerName
|
||
|
in the UFO. If layerName is not provided,
|
||
|
the name retrieved with getDefaultLayerName
|
||
|
will be used.
|
||
|
|
||
|
``validateRead`` will validate the read data, by default it is set to the
|
||
|
class's validate value, can be overridden.
|
||
|
``validateWrite`` will validate the written data, by default it is set to the
|
||
|
class's validate value, can be overridden.
|
||
|
"""
|
||
|
from fontTools.ufoLib.glifLib import GlyphSet
|
||
|
|
||
|
if validateRead is None:
|
||
|
validateRead = self._validate
|
||
|
if validateWrite is None:
|
||
|
validateWrite = self._validate
|
||
|
if layerName is None:
|
||
|
layerName = self.getDefaultLayerName(validate=validateRead)
|
||
|
directory = None
|
||
|
layerContents = self._readLayerContents(validateRead)
|
||
|
for storedLayerName, storedLayerDirectory in layerContents:
|
||
|
if layerName == storedLayerName:
|
||
|
directory = storedLayerDirectory
|
||
|
break
|
||
|
if directory is None:
|
||
|
raise UFOLibError('No glyphs directory is mapped to "%s".' % layerName)
|
||
|
try:
|
||
|
glyphSubFS = self.fs.opendir(directory)
|
||
|
except fs.errors.ResourceNotFound:
|
||
|
raise UFOLibError(f"No '{directory}' directory for layer '{layerName}'")
|
||
|
return GlyphSet(
|
||
|
glyphSubFS,
|
||
|
ufoFormatVersion=self._formatVersion,
|
||
|
validateRead=validateRead,
|
||
|
validateWrite=validateWrite,
|
||
|
expectContentsFile=True,
|
||
|
)
|
||
|
|
||
|
def getCharacterMapping(self, layerName=None, validate=None):
|
||
|
"""
|
||
|
Return a dictionary that maps unicode values (ints) to
|
||
|
lists of glyph names.
|
||
|
"""
|
||
|
if validate is None:
|
||
|
validate = self._validate
|
||
|
glyphSet = self.getGlyphSet(
|
||
|
layerName, validateRead=validate, validateWrite=True
|
||
|
)
|
||
|
allUnicodes = glyphSet.getUnicodes()
|
||
|
cmap = {}
|
||
|
for glyphName, unicodes in allUnicodes.items():
|
||
|
for code in unicodes:
|
||
|
if code in cmap:
|
||
|
cmap[code].append(glyphName)
|
||
|
else:
|
||
|
cmap[code] = [glyphName]
|
||
|
return cmap
|
||
|
|
||
|
# /data
|
||
|
|
||
|
def getDataDirectoryListing(self):
|
||
|
"""
|
||
|
Returns a list of all files in the data directory.
|
||
|
The returned paths will be relative to the UFO.
|
||
|
This will not list directory names, only file names.
|
||
|
Thus, empty directories will be skipped.
|
||
|
"""
|
||
|
try:
|
||
|
self._dataFS = self.fs.opendir(DATA_DIRNAME)
|
||
|
except fs.errors.ResourceNotFound:
|
||
|
return []
|
||
|
except fs.errors.DirectoryExpected:
|
||
|
raise UFOLibError('The UFO contains a "data" file instead of a directory.')
|
||
|
try:
|
||
|
# fs Walker.files method returns "absolute" paths (in terms of the
|
||
|
# root of the 'data' SubFS), so we strip the leading '/' to make
|
||
|
# them relative
|
||
|
return [p.lstrip("/") for p in self._dataFS.walk.files()]
|
||
|
except fs.errors.ResourceError:
|
||
|
return []
|
||
|
|
||
|
def getImageDirectoryListing(self, validate=None):
|
||
|
"""
|
||
|
Returns a list of all image file names in
|
||
|
the images directory. Each of the images will
|
||
|
have been verified to have the PNG signature.
|
||
|
|
||
|
``validate`` will validate the data, by default it is set to the
|
||
|
class's validate value, can be overridden.
|
||
|
"""
|
||
|
if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
|
||
|
return []
|
||
|
if validate is None:
|
||
|
validate = self._validate
|
||
|
try:
|
||
|
self._imagesFS = imagesFS = self.fs.opendir(IMAGES_DIRNAME)
|
||
|
except fs.errors.ResourceNotFound:
|
||
|
return []
|
||
|
except fs.errors.DirectoryExpected:
|
||
|
raise UFOLibError(
|
||
|
'The UFO contains an "images" file instead of a directory.'
|
||
|
)
|
||
|
result = []
|
||
|
for path in imagesFS.scandir("/"):
|
||
|
if path.is_dir:
|
||
|
# silently skip this as version control
|
||
|
# systems often have hidden directories
|
||
|
continue
|
||
|
if validate:
|
||
|
with imagesFS.openbin(path.name) as fp:
|
||
|
valid, error = pngValidator(fileObj=fp)
|
||
|
if valid:
|
||
|
result.append(path.name)
|
||
|
else:
|
||
|
result.append(path.name)
|
||
|
return result
|
||
|
|
||
|
def readData(self, fileName):
|
||
|
"""
|
||
|
Return bytes for the file named 'fileName' inside the 'data/' directory.
|
||
|
"""
|
||
|
fileName = fsdecode(fileName)
|
||
|
try:
|
||
|
try:
|
||
|
dataFS = self._dataFS
|
||
|
except AttributeError:
|
||
|
# in case readData is called before getDataDirectoryListing
|
||
|
dataFS = self.fs.opendir(DATA_DIRNAME)
|
||
|
data = dataFS.readbytes(fileName)
|
||
|
except fs.errors.ResourceNotFound:
|
||
|
raise UFOLibError(f"No data file named '{fileName}' on {self.fs}")
|
||
|
return data
|
||
|
|
||
|
def readImage(self, fileName, validate=None):
|
||
|
"""
|
||
|
Return image data for the file named fileName.
|
||
|
|
||
|
``validate`` will validate the data, by default it is set to the
|
||
|
class's validate value, can be overridden.
|
||
|
"""
|
||
|
if validate is None:
|
||
|
validate = self._validate
|
||
|
if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
|
||
|
raise UFOLibError(
|
||
|
f"Reading images is not allowed in UFO {self._formatVersion.major}."
|
||
|
)
|
||
|
fileName = fsdecode(fileName)
|
||
|
try:
|
||
|
try:
|
||
|
imagesFS = self._imagesFS
|
||
|
except AttributeError:
|
||
|
# in case readImage is called before getImageDirectoryListing
|
||
|
imagesFS = self.fs.opendir(IMAGES_DIRNAME)
|
||
|
data = imagesFS.readbytes(fileName)
|
||
|
except fs.errors.ResourceNotFound:
|
||
|
raise UFOLibError(f"No image file named '{fileName}' on {self.fs}")
|
||
|
if validate:
|
||
|
valid, error = pngValidator(data=data)
|
||
|
if not valid:
|
||
|
raise UFOLibError(error)
|
||
|
return data
|
||
|
|
||
|
def close(self):
|
||
|
if self._shouldClose:
|
||
|
self.fs.close()
|
||
|
|
||
|
def __enter__(self):
|
||
|
return self
|
||
|
|
||
|
def __exit__(self, exc_type, exc_value, exc_tb):
|
||
|
self.close()
|
||
|
|
||
|
|
||
|
# ----------
|
||
|
# UFO Writer
|
||
|
# ----------
|
||
|
|
||
|
|
||
|
class UFOWriter(UFOReader):
|
||
|
"""
|
||
|
Write the various components of the .ufo.
|
||
|
|
||
|
By default, the written data will be validated before writing. Set ``validate`` to
|
||
|
``False`` if you do not want to validate the data. Validation can also be overriden
|
||
|
on a per method level if desired.
|
||
|
|
||
|
The ``formatVersion`` argument allows to specify the UFO format version as a tuple
|
||
|
of integers (major, minor), or as a single integer for the major digit only (minor
|
||
|
is implied as 0). By default the latest formatVersion will be used; currently it's
|
||
|
3.0, which is equivalent to formatVersion=(3, 0).
|
||
|
|
||
|
An UnsupportedUFOFormat exception is raised if the requested UFO formatVersion is
|
||
|
not supported.
|
||
|
"""
|
||
|
|
||
|
def __init__(
|
||
|
self,
|
||
|
path,
|
||
|
formatVersion=None,
|
||
|
fileCreator="com.github.fonttools.ufoLib",
|
||
|
structure=None,
|
||
|
validate=True,
|
||
|
):
|
||
|
try:
|
||
|
formatVersion = UFOFormatVersion(formatVersion)
|
||
|
except ValueError as e:
|
||
|
from fontTools.ufoLib.errors import UnsupportedUFOFormat
|
||
|
|
||
|
raise UnsupportedUFOFormat(
|
||
|
f"Unsupported UFO format: {formatVersion!r}"
|
||
|
) from e
|
||
|
|
||
|
if hasattr(path, "__fspath__"): # support os.PathLike objects
|
||
|
path = path.__fspath__()
|
||
|
|
||
|
if isinstance(path, str):
|
||
|
# normalize path by removing trailing or double slashes
|
||
|
path = os.path.normpath(path)
|
||
|
havePreviousFile = os.path.exists(path)
|
||
|
if havePreviousFile:
|
||
|
# ensure we use the same structure as the destination
|
||
|
existingStructure = _sniffFileStructure(path)
|
||
|
if structure is not None:
|
||
|
try:
|
||
|
structure = UFOFileStructure(structure)
|
||
|
except ValueError:
|
||
|
raise UFOLibError(
|
||
|
"Invalid or unsupported structure: '%s'" % structure
|
||
|
)
|
||
|
if structure is not existingStructure:
|
||
|
raise UFOLibError(
|
||
|
"A UFO with a different structure (%s) already exists "
|
||
|
"at the given path: '%s'" % (existingStructure, path)
|
||
|
)
|
||
|
else:
|
||
|
structure = existingStructure
|
||
|
else:
|
||
|
# if not exists, default to 'package' structure
|
||
|
if structure is None:
|
||
|
structure = UFOFileStructure.PACKAGE
|
||
|
dirName = os.path.dirname(path)
|
||
|
if dirName and not os.path.isdir(dirName):
|
||
|
raise UFOLibError(
|
||
|
"Cannot write to '%s': directory does not exist" % path
|
||
|
)
|
||
|
if structure is UFOFileStructure.ZIP:
|
||
|
if havePreviousFile:
|
||
|
# we can't write a zip in-place, so we have to copy its
|
||
|
# contents to a temporary location and work from there, then
|
||
|
# upon closing UFOWriter we create the final zip file
|
||
|
parentFS = fs.tempfs.TempFS()
|
||
|
with fs.zipfs.ZipFS(path, encoding="utf-8") as origFS:
|
||
|
fs.copy.copy_fs(origFS, parentFS)
|
||
|
# if output path is an existing zip, we require that it contains
|
||
|
# one, and only one, root directory (with arbitrary name), in turn
|
||
|
# containing all the existing UFO contents
|
||
|
rootDirs = [
|
||
|
p.name
|
||
|
for p in parentFS.scandir("/")
|
||
|
# exclude macOS metadata contained in zip file
|
||
|
if p.is_dir and p.name != "__MACOSX"
|
||
|
]
|
||
|
if len(rootDirs) != 1:
|
||
|
raise UFOLibError(
|
||
|
"Expected exactly 1 root directory, found %d"
|
||
|
% len(rootDirs)
|
||
|
)
|
||
|
else:
|
||
|
# 'ClosingSubFS' ensures that the parent filesystem is closed
|
||
|
# when its root subdirectory is closed
|
||
|
self.fs = parentFS.opendir(
|
||
|
rootDirs[0], factory=fs.subfs.ClosingSubFS
|
||
|
)
|
||
|
else:
|
||
|
# if the output zip file didn't exist, we create the root folder;
|
||
|
# we name it the same as input 'path', but with '.ufo' extension
|
||
|
rootDir = os.path.splitext(os.path.basename(path))[0] + ".ufo"
|
||
|
parentFS = fs.zipfs.ZipFS(path, write=True, encoding="utf-8")
|
||
|
parentFS.makedir(rootDir)
|
||
|
self.fs = parentFS.opendir(rootDir, factory=fs.subfs.ClosingSubFS)
|
||
|
else:
|
||
|
self.fs = fs.osfs.OSFS(path, create=True)
|
||
|
self._fileStructure = structure
|
||
|
self._havePreviousFile = havePreviousFile
|
||
|
self._shouldClose = True
|
||
|
elif isinstance(path, fs.base.FS):
|
||
|
filesystem = path
|
||
|
try:
|
||
|
filesystem.check()
|
||
|
except fs.errors.FilesystemClosed:
|
||
|
raise UFOLibError("the filesystem '%s' is closed" % path)
|
||
|
else:
|
||
|
self.fs = filesystem
|
||
|
try:
|
||
|
path = filesystem.getsyspath("/")
|
||
|
except fs.errors.NoSysPath:
|
||
|
# network or in-memory FS may not map to the local one
|
||
|
path = str(filesystem)
|
||
|
# if passed an FS object, always use 'package' structure
|
||
|
if structure and structure is not UFOFileStructure.PACKAGE:
|
||
|
import warnings
|
||
|
|
||
|
warnings.warn(
|
||
|
"The 'structure' argument is not used when input is an FS object",
|
||
|
UserWarning,
|
||
|
stacklevel=2,
|
||
|
)
|
||
|
self._fileStructure = UFOFileStructure.PACKAGE
|
||
|
# if FS contains a "metainfo.plist", we consider it non-empty
|
||
|
self._havePreviousFile = filesystem.exists(METAINFO_FILENAME)
|
||
|
# the user is responsible for closing the FS object
|
||
|
self._shouldClose = False
|
||
|
else:
|
||
|
raise TypeError(
|
||
|
"Expected a path string or fs object, found %s" % type(path).__name__
|
||
|
)
|
||
|
|
||
|
# establish some basic stuff
|
||
|
self._path = fsdecode(path)
|
||
|
self._formatVersion = formatVersion
|
||
|
self._fileCreator = fileCreator
|
||
|
self._downConversionKerningData = None
|
||
|
self._validate = validate
|
||
|
# if the file already exists, get the format version.
|
||
|
# this will be needed for up and down conversion.
|
||
|
previousFormatVersion = None
|
||
|
if self._havePreviousFile:
|
||
|
metaInfo = self._readMetaInfo(validate=validate)
|
||
|
previousFormatVersion = metaInfo["formatVersionTuple"]
|
||
|
# catch down conversion
|
||
|
if previousFormatVersion > formatVersion:
|
||
|
from fontTools.ufoLib.errors import UnsupportedUFOFormat
|
||
|
|
||
|
raise UnsupportedUFOFormat(
|
||
|
"The UFO located at this path is a higher version "
|
||
|
f"({previousFormatVersion}) than the version ({formatVersion}) "
|
||
|
"that is trying to be written. This is not supported."
|
||
|
)
|
||
|
# handle the layer contents
|
||
|
self.layerContents = {}
|
||
|
if previousFormatVersion is not None and previousFormatVersion.major >= 3:
|
||
|
# already exists
|
||
|
self.layerContents = OrderedDict(self._readLayerContents(validate))
|
||
|
else:
|
||
|
# previous < 3
|
||
|
# imply the layer contents
|
||
|
if self.fs.exists(DEFAULT_GLYPHS_DIRNAME):
|
||
|
self.layerContents = {DEFAULT_LAYER_NAME: DEFAULT_GLYPHS_DIRNAME}
|
||
|
# write the new metainfo
|
||
|
self._writeMetaInfo()
|
||
|
|
||
|
# properties
|
||
|
|
||
|
def _get_fileCreator(self):
|
||
|
return self._fileCreator
|
||
|
|
||
|
fileCreator = property(
|
||
|
_get_fileCreator,
|
||
|
doc="The file creator of the UFO. This is set into metainfo.plist during __init__.",
|
||
|
)
|
||
|
|
||
|
# support methods for file system interaction
|
||
|
|
||
|
def copyFromReader(self, reader, sourcePath, destPath):
|
||
|
"""
|
||
|
Copy the sourcePath in the provided UFOReader to destPath
|
||
|
in this writer. The paths must be relative. This works with
|
||
|
both individual files and directories.
|
||
|
"""
|
||
|
if not isinstance(reader, UFOReader):
|
||
|
raise UFOLibError("The reader must be an instance of UFOReader.")
|
||
|
sourcePath = fsdecode(sourcePath)
|
||
|
destPath = fsdecode(destPath)
|
||
|
if not reader.fs.exists(sourcePath):
|
||
|
raise UFOLibError(
|
||
|
'The reader does not have data located at "%s".' % sourcePath
|
||
|
)
|
||
|
if self.fs.exists(destPath):
|
||
|
raise UFOLibError('A file named "%s" already exists.' % destPath)
|
||
|
# create the destination directory if it doesn't exist
|
||
|
self.fs.makedirs(fs.path.dirname(destPath), recreate=True)
|
||
|
if reader.fs.isdir(sourcePath):
|
||
|
fs.copy.copy_dir(reader.fs, sourcePath, self.fs, destPath)
|
||
|
else:
|
||
|
fs.copy.copy_file(reader.fs, sourcePath, self.fs, destPath)
|
||
|
|
||
|
def writeBytesToPath(self, path, data):
|
||
|
"""
|
||
|
Write bytes to a path relative to the UFO filesystem's root.
|
||
|
If writing to an existing UFO, check to see if data matches the data
|
||
|
that is already in the file at path; if so, the file is not rewritten
|
||
|
so that the modification date is preserved.
|
||
|
If needed, the directory tree for the given path will be built.
|
||
|
"""
|
||
|
path = fsdecode(path)
|
||
|
if self._havePreviousFile:
|
||
|
if self.fs.isfile(path) and data == self.fs.readbytes(path):
|
||
|
return
|
||
|
try:
|
||
|
self.fs.writebytes(path, data)
|
||
|
except fs.errors.FileExpected:
|
||
|
raise UFOLibError("A directory exists at '%s'" % path)
|
||
|
except fs.errors.ResourceNotFound:
|
||
|
self.fs.makedirs(fs.path.dirname(path), recreate=True)
|
||
|
self.fs.writebytes(path, data)
|
||
|
|
||
|
def getFileObjectForPath(self, path, mode="w", encoding=None):
|
||
|
"""
|
||
|
Returns a file (or file-like) object for the
|
||
|
file at the given path. The path must be relative
|
||
|
to the UFO path. Returns None if the file does
|
||
|
not exist and the mode is "r" or "rb.
|
||
|
An encoding may be passed if the file is opened in text mode.
|
||
|
|
||
|
Note: The caller is responsible for closing the open file.
|
||
|
"""
|
||
|
path = fsdecode(path)
|
||
|
try:
|
||
|
return self.fs.open(path, mode=mode, encoding=encoding)
|
||
|
except fs.errors.ResourceNotFound as e:
|
||
|
m = mode[0]
|
||
|
if m == "r":
|
||
|
# XXX I think we should just let it raise. The docstring,
|
||
|
# however, says that this returns None if mode is 'r'
|
||
|
return None
|
||
|
elif m == "w" or m == "a" or m == "x":
|
||
|
self.fs.makedirs(fs.path.dirname(path), recreate=True)
|
||
|
return self.fs.open(path, mode=mode, encoding=encoding)
|
||
|
except fs.errors.ResourceError as e:
|
||
|
return UFOLibError(f"unable to open '{path}' on {self.fs}: {e}")
|
||
|
|
||
|
def removePath(self, path, force=False, removeEmptyParents=True):
|
||
|
"""
|
||
|
Remove the file (or directory) at path. The path
|
||
|
must be relative to the UFO.
|
||
|
Raises UFOLibError if the path doesn't exist.
|
||
|
If force=True, ignore non-existent paths.
|
||
|
If the directory where 'path' is located becomes empty, it will
|
||
|
be automatically removed, unless 'removeEmptyParents' is False.
|
||
|
"""
|
||
|
path = fsdecode(path)
|
||
|
try:
|
||
|
self.fs.remove(path)
|
||
|
except fs.errors.FileExpected:
|
||
|
self.fs.removetree(path)
|
||
|
except fs.errors.ResourceNotFound:
|
||
|
if not force:
|
||
|
raise UFOLibError(f"'{path}' does not exist on {self.fs}")
|
||
|
if removeEmptyParents:
|
||
|
parent = fs.path.dirname(path)
|
||
|
if parent:
|
||
|
fs.tools.remove_empty(self.fs, parent)
|
||
|
|
||
|
# alias kept for backward compatibility with old API
|
||
|
removeFileForPath = removePath
|
||
|
|
||
|
# UFO mod time
|
||
|
|
||
|
def setModificationTime(self):
|
||
|
"""
|
||
|
Set the UFO modification time to the current time.
|
||
|
This is never called automatically. It is up to the
|
||
|
caller to call this when finished working on the UFO.
|
||
|
"""
|
||
|
path = self._path
|
||
|
if path is not None and os.path.exists(path):
|
||
|
try:
|
||
|
# this may fail on some filesystems (e.g. SMB servers)
|
||
|
os.utime(path, None)
|
||
|
except OSError as e:
|
||
|
logger.warning("Failed to set modified time: %s", e)
|
||
|
|
||
|
# metainfo.plist
|
||
|
|
||
|
def _writeMetaInfo(self):
|
||
|
metaInfo = dict(
|
||
|
creator=self._fileCreator,
|
||
|
formatVersion=self._formatVersion.major,
|
||
|
)
|
||
|
if self._formatVersion.minor != 0:
|
||
|
metaInfo["formatVersionMinor"] = self._formatVersion.minor
|
||
|
self._writePlist(METAINFO_FILENAME, metaInfo)
|
||
|
|
||
|
# groups.plist
|
||
|
|
||
|
def setKerningGroupConversionRenameMaps(self, maps):
|
||
|
"""
|
||
|
Set maps defining the renaming that should be done
|
||
|
when writing groups and kerning in UFO 1 and UFO 2.
|
||
|
This will effectively undo the conversion done when
|
||
|
UFOReader reads this data. The dictionary should have
|
||
|
this form::
|
||
|
|
||
|
{
|
||
|
"side1" : {"group name to use when writing" : "group name in data"},
|
||
|
"side2" : {"group name to use when writing" : "group name in data"}
|
||
|
}
|
||
|
|
||
|
This is the same form returned by UFOReader's
|
||
|
getKerningGroupConversionRenameMaps method.
|
||
|
"""
|
||
|
if self._formatVersion >= UFOFormatVersion.FORMAT_3_0:
|
||
|
return # XXX raise an error here
|
||
|
# flip the dictionaries
|
||
|
remap = {}
|
||
|
for side in ("side1", "side2"):
|
||
|
for writeName, dataName in list(maps[side].items()):
|
||
|
remap[dataName] = writeName
|
||
|
self._downConversionKerningData = dict(groupRenameMap=remap)
|
||
|
|
||
|
def writeGroups(self, groups, validate=None):
|
||
|
"""
|
||
|
Write groups.plist. This method requires a
|
||
|
dict of glyph groups as an argument.
|
||
|
|
||
|
``validate`` will validate the data, by default it is set to the
|
||
|
class's validate value, can be overridden.
|
||
|
"""
|
||
|
if validate is None:
|
||
|
validate = self._validate
|
||
|
# validate the data structure
|
||
|
if validate:
|
||
|
valid, message = groupsValidator(groups)
|
||
|
if not valid:
|
||
|
raise UFOLibError(message)
|
||
|
# down convert
|
||
|
if (
|
||
|
self._formatVersion < UFOFormatVersion.FORMAT_3_0
|
||
|
and self._downConversionKerningData is not None
|
||
|
):
|
||
|
remap = self._downConversionKerningData["groupRenameMap"]
|
||
|
remappedGroups = {}
|
||
|
# there are some edge cases here that are ignored:
|
||
|
# 1. if a group is being renamed to a name that
|
||
|
# already exists, the existing group is always
|
||
|
# overwritten. (this is why there are two loops
|
||
|
# below.) there doesn't seem to be a logical
|
||
|
# solution to groups mismatching and overwriting
|
||
|
# with the specifiecd group seems like a better
|
||
|
# solution than throwing an error.
|
||
|
# 2. if side 1 and side 2 groups are being renamed
|
||
|
# to the same group name there is no check to
|
||
|
# ensure that the contents are identical. that
|
||
|
# is left up to the caller.
|
||
|
for name, contents in list(groups.items()):
|
||
|
if name in remap:
|
||
|
continue
|
||
|
remappedGroups[name] = contents
|
||
|
for name, contents in list(groups.items()):
|
||
|
if name not in remap:
|
||
|
continue
|
||
|
name = remap[name]
|
||
|
remappedGroups[name] = contents
|
||
|
groups = remappedGroups
|
||
|
# pack and write
|
||
|
groupsNew = {}
|
||
|
for key, value in groups.items():
|
||
|
groupsNew[key] = list(value)
|
||
|
if groupsNew:
|
||
|
self._writePlist(GROUPS_FILENAME, groupsNew)
|
||
|
elif self._havePreviousFile:
|
||
|
self.removePath(GROUPS_FILENAME, force=True, removeEmptyParents=False)
|
||
|
|
||
|
# fontinfo.plist
|
||
|
|
||
|
def writeInfo(self, info, validate=None):
|
||
|
"""
|
||
|
Write info.plist. This method requires an object
|
||
|
that supports getting attributes that follow the
|
||
|
fontinfo.plist version 2 specification. Attributes
|
||
|
will be taken from the given object and written
|
||
|
into the file.
|
||
|
|
||
|
``validate`` will validate the data, by default it is set to the
|
||
|
class's validate value, can be overridden.
|
||
|
"""
|
||
|
if validate is None:
|
||
|
validate = self._validate
|
||
|
# gather version 3 data
|
||
|
infoData = {}
|
||
|
for attr in list(fontInfoAttributesVersion3ValueData.keys()):
|
||
|
if hasattr(info, attr):
|
||
|
try:
|
||
|
value = getattr(info, attr)
|
||
|
except AttributeError:
|
||
|
raise UFOLibError(
|
||
|
"The supplied info object does not support getting a necessary attribute (%s)."
|
||
|
% attr
|
||
|
)
|
||
|
if value is None:
|
||
|
continue
|
||
|
infoData[attr] = value
|
||
|
# down convert data if necessary and validate
|
||
|
if self._formatVersion == UFOFormatVersion.FORMAT_3_0:
|
||
|
if validate:
|
||
|
infoData = validateInfoVersion3Data(infoData)
|
||
|
elif self._formatVersion == UFOFormatVersion.FORMAT_2_0:
|
||
|
infoData = _convertFontInfoDataVersion3ToVersion2(infoData)
|
||
|
if validate:
|
||
|
infoData = validateInfoVersion2Data(infoData)
|
||
|
elif self._formatVersion == UFOFormatVersion.FORMAT_1_0:
|
||
|
infoData = _convertFontInfoDataVersion3ToVersion2(infoData)
|
||
|
if validate:
|
||
|
infoData = validateInfoVersion2Data(infoData)
|
||
|
infoData = _convertFontInfoDataVersion2ToVersion1(infoData)
|
||
|
# write file if there is anything to write
|
||
|
if infoData:
|
||
|
self._writePlist(FONTINFO_FILENAME, infoData)
|
||
|
|
||
|
# kerning.plist
|
||
|
|
||
|
def writeKerning(self, kerning, validate=None):
|
||
|
"""
|
||
|
Write kerning.plist. This method requires a
|
||
|
dict of kerning pairs as an argument.
|
||
|
|
||
|
This performs basic structural validation of the kerning,
|
||
|
but it does not check for compliance with the spec in
|
||
|
regards to conflicting pairs. The assumption is that the
|
||
|
kerning data being passed is standards compliant.
|
||
|
|
||
|
``validate`` will validate the data, by default it is set to the
|
||
|
class's validate value, can be overridden.
|
||
|
"""
|
||
|
if validate is None:
|
||
|
validate = self._validate
|
||
|
# validate the data structure
|
||
|
if validate:
|
||
|
invalidFormatMessage = "The kerning is not properly formatted."
|
||
|
if not isDictEnough(kerning):
|
||
|
raise UFOLibError(invalidFormatMessage)
|
||
|
for pair, value in list(kerning.items()):
|
||
|
if not isinstance(pair, (list, tuple)):
|
||
|
raise UFOLibError(invalidFormatMessage)
|
||
|
if not len(pair) == 2:
|
||
|
raise UFOLibError(invalidFormatMessage)
|
||
|
if not isinstance(pair[0], str):
|
||
|
raise UFOLibError(invalidFormatMessage)
|
||
|
if not isinstance(pair[1], str):
|
||
|
raise UFOLibError(invalidFormatMessage)
|
||
|
if not isinstance(value, numberTypes):
|
||
|
raise UFOLibError(invalidFormatMessage)
|
||
|
# down convert
|
||
|
if (
|
||
|
self._formatVersion < UFOFormatVersion.FORMAT_3_0
|
||
|
and self._downConversionKerningData is not None
|
||
|
):
|
||
|
remap = self._downConversionKerningData["groupRenameMap"]
|
||
|
remappedKerning = {}
|
||
|
for (side1, side2), value in list(kerning.items()):
|
||
|
side1 = remap.get(side1, side1)
|
||
|
side2 = remap.get(side2, side2)
|
||
|
remappedKerning[side1, side2] = value
|
||
|
kerning = remappedKerning
|
||
|
# pack and write
|
||
|
kerningDict = {}
|
||
|
for left, right in kerning.keys():
|
||
|
value = kerning[left, right]
|
||
|
if left not in kerningDict:
|
||
|
kerningDict[left] = {}
|
||
|
kerningDict[left][right] = value
|
||
|
if kerningDict:
|
||
|
self._writePlist(KERNING_FILENAME, kerningDict)
|
||
|
elif self._havePreviousFile:
|
||
|
self.removePath(KERNING_FILENAME, force=True, removeEmptyParents=False)
|
||
|
|
||
|
# lib.plist
|
||
|
|
||
|
def writeLib(self, libDict, validate=None):
|
||
|
"""
|
||
|
Write lib.plist. This method requires a
|
||
|
lib dict as an argument.
|
||
|
|
||
|
``validate`` will validate the data, by default it is set to the
|
||
|
class's validate value, can be overridden.
|
||
|
"""
|
||
|
if validate is None:
|
||
|
validate = self._validate
|
||
|
if validate:
|
||
|
valid, message = fontLibValidator(libDict)
|
||
|
if not valid:
|
||
|
raise UFOLibError(message)
|
||
|
if libDict:
|
||
|
self._writePlist(LIB_FILENAME, libDict)
|
||
|
elif self._havePreviousFile:
|
||
|
self.removePath(LIB_FILENAME, force=True, removeEmptyParents=False)
|
||
|
|
||
|
# features.fea
|
||
|
|
||
|
def writeFeatures(self, features, validate=None):
|
||
|
"""
|
||
|
Write features.fea. This method requires a
|
||
|
features string as an argument.
|
||
|
"""
|
||
|
if validate is None:
|
||
|
validate = self._validate
|
||
|
if self._formatVersion == UFOFormatVersion.FORMAT_1_0:
|
||
|
raise UFOLibError("features.fea is not allowed in UFO Format Version 1.")
|
||
|
if validate:
|
||
|
if not isinstance(features, str):
|
||
|
raise UFOLibError("The features are not text.")
|
||
|
if features:
|
||
|
self.writeBytesToPath(FEATURES_FILENAME, features.encode("utf8"))
|
||
|
elif self._havePreviousFile:
|
||
|
self.removePath(FEATURES_FILENAME, force=True, removeEmptyParents=False)
|
||
|
|
||
|
# glyph sets & layers
|
||
|
|
||
|
def writeLayerContents(self, layerOrder=None, validate=None):
|
||
|
"""
|
||
|
Write the layercontents.plist file. This method *must* be called
|
||
|
after all glyph sets have been written.
|
||
|
"""
|
||
|
if validate is None:
|
||
|
validate = self._validate
|
||
|
if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
|
||
|
return
|
||
|
if layerOrder is not None:
|
||
|
newOrder = []
|
||
|
for layerName in layerOrder:
|
||
|
if layerName is None:
|
||
|
layerName = DEFAULT_LAYER_NAME
|
||
|
newOrder.append(layerName)
|
||
|
layerOrder = newOrder
|
||
|
else:
|
||
|
layerOrder = list(self.layerContents.keys())
|
||
|
if validate and set(layerOrder) != set(self.layerContents.keys()):
|
||
|
raise UFOLibError(
|
||
|
"The layer order content does not match the glyph sets that have been created."
|
||
|
)
|
||
|
layerContents = [
|
||
|
(layerName, self.layerContents[layerName]) for layerName in layerOrder
|
||
|
]
|
||
|
self._writePlist(LAYERCONTENTS_FILENAME, layerContents)
|
||
|
|
||
|
def _findDirectoryForLayerName(self, layerName):
|
||
|
foundDirectory = None
|
||
|
for existingLayerName, directoryName in list(self.layerContents.items()):
|
||
|
if layerName is None and directoryName == DEFAULT_GLYPHS_DIRNAME:
|
||
|
foundDirectory = directoryName
|
||
|
break
|
||
|
elif existingLayerName == layerName:
|
||
|
foundDirectory = directoryName
|
||
|
break
|
||
|
if not foundDirectory:
|
||
|
raise UFOLibError(
|
||
|
"Could not locate a glyph set directory for the layer named %s."
|
||
|
% layerName
|
||
|
)
|
||
|
return foundDirectory
|
||
|
|
||
|
def getGlyphSet(
|
||
|
self,
|
||
|
layerName=None,
|
||
|
defaultLayer=True,
|
||
|
glyphNameToFileNameFunc=None,
|
||
|
validateRead=None,
|
||
|
validateWrite=None,
|
||
|
expectContentsFile=False,
|
||
|
):
|
||
|
"""
|
||
|
Return the GlyphSet object associated with the
|
||
|
appropriate glyph directory in the .ufo.
|
||
|
If layerName is None, the default glyph set
|
||
|
will be used. The defaultLayer flag indictes
|
||
|
that the layer should be saved into the default
|
||
|
glyphs directory.
|
||
|
|
||
|
``validateRead`` will validate the read data, by default it is set to the
|
||
|
class's validate value, can be overridden.
|
||
|
``validateWrte`` will validate the written data, by default it is set to the
|
||
|
class's validate value, can be overridden.
|
||
|
``expectContentsFile`` will raise a GlifLibError if a contents.plist file is
|
||
|
not found on the glyph set file system. This should be set to ``True`` if you
|
||
|
are reading an existing UFO and ``False`` if you use ``getGlyphSet`` to create
|
||
|
a fresh glyph set.
|
||
|
"""
|
||
|
if validateRead is None:
|
||
|
validateRead = self._validate
|
||
|
if validateWrite is None:
|
||
|
validateWrite = self._validate
|
||
|
# only default can be written in < 3
|
||
|
if self._formatVersion < UFOFormatVersion.FORMAT_3_0 and (
|
||
|
not defaultLayer or layerName is not None
|
||
|
):
|
||
|
raise UFOLibError(
|
||
|
f"Only the default layer can be writen in UFO {self._formatVersion.major}."
|
||
|
)
|
||
|
# locate a layer name when None has been given
|
||
|
if layerName is None and defaultLayer:
|
||
|
for existingLayerName, directory in self.layerContents.items():
|
||
|
if directory == DEFAULT_GLYPHS_DIRNAME:
|
||
|
layerName = existingLayerName
|
||
|
if layerName is None:
|
||
|
layerName = DEFAULT_LAYER_NAME
|
||
|
elif layerName is None and not defaultLayer:
|
||
|
raise UFOLibError("A layer name must be provided for non-default layers.")
|
||
|
# move along to format specific writing
|
||
|
if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
|
||
|
return self._getDefaultGlyphSet(
|
||
|
validateRead,
|
||
|
validateWrite,
|
||
|
glyphNameToFileNameFunc=glyphNameToFileNameFunc,
|
||
|
expectContentsFile=expectContentsFile,
|
||
|
)
|
||
|
elif self._formatVersion.major == UFOFormatVersion.FORMAT_3_0.major:
|
||
|
return self._getGlyphSetFormatVersion3(
|
||
|
validateRead,
|
||
|
validateWrite,
|
||
|
layerName=layerName,
|
||
|
defaultLayer=defaultLayer,
|
||
|
glyphNameToFileNameFunc=glyphNameToFileNameFunc,
|
||
|
expectContentsFile=expectContentsFile,
|
||
|
)
|
||
|
else:
|
||
|
raise NotImplementedError(self._formatVersion)
|
||
|
|
||
|
def _getDefaultGlyphSet(
|
||
|
self,
|
||
|
validateRead,
|
||
|
validateWrite,
|
||
|
glyphNameToFileNameFunc=None,
|
||
|
expectContentsFile=False,
|
||
|
):
|
||
|
from fontTools.ufoLib.glifLib import GlyphSet
|
||
|
|
||
|
glyphSubFS = self.fs.makedir(DEFAULT_GLYPHS_DIRNAME, recreate=True)
|
||
|
return GlyphSet(
|
||
|
glyphSubFS,
|
||
|
glyphNameToFileNameFunc=glyphNameToFileNameFunc,
|
||
|
ufoFormatVersion=self._formatVersion,
|
||
|
validateRead=validateRead,
|
||
|
validateWrite=validateWrite,
|
||
|
expectContentsFile=expectContentsFile,
|
||
|
)
|
||
|
|
||
|
def _getGlyphSetFormatVersion3(
|
||
|
self,
|
||
|
validateRead,
|
||
|
validateWrite,
|
||
|
layerName=None,
|
||
|
defaultLayer=True,
|
||
|
glyphNameToFileNameFunc=None,
|
||
|
expectContentsFile=False,
|
||
|
):
|
||
|
from fontTools.ufoLib.glifLib import GlyphSet
|
||
|
|
||
|
# if the default flag is on, make sure that the default in the file
|
||
|
# matches the default being written. also make sure that this layer
|
||
|
# name is not already linked to a non-default layer.
|
||
|
if defaultLayer:
|
||
|
for existingLayerName, directory in self.layerContents.items():
|
||
|
if directory == DEFAULT_GLYPHS_DIRNAME:
|
||
|
if existingLayerName != layerName:
|
||
|
raise UFOLibError(
|
||
|
"Another layer ('%s') is already mapped to the default directory."
|
||
|
% existingLayerName
|
||
|
)
|
||
|
elif existingLayerName == layerName:
|
||
|
raise UFOLibError(
|
||
|
"The layer name is already mapped to a non-default layer."
|
||
|
)
|
||
|
# get an existing directory name
|
||
|
if layerName in self.layerContents:
|
||
|
directory = self.layerContents[layerName]
|
||
|
# get a new directory name
|
||
|
else:
|
||
|
if defaultLayer:
|
||
|
directory = DEFAULT_GLYPHS_DIRNAME
|
||
|
else:
|
||
|
# not caching this could be slightly expensive,
|
||
|
# but caching it will be cumbersome
|
||
|
existing = {d.lower() for d in self.layerContents.values()}
|
||
|
directory = userNameToFileName(
|
||
|
layerName, existing=existing, prefix="glyphs."
|
||
|
)
|
||
|
# make the directory
|
||
|
glyphSubFS = self.fs.makedir(directory, recreate=True)
|
||
|
# store the mapping
|
||
|
self.layerContents[layerName] = directory
|
||
|
# load the glyph set
|
||
|
return GlyphSet(
|
||
|
glyphSubFS,
|
||
|
glyphNameToFileNameFunc=glyphNameToFileNameFunc,
|
||
|
ufoFormatVersion=self._formatVersion,
|
||
|
validateRead=validateRead,
|
||
|
validateWrite=validateWrite,
|
||
|
expectContentsFile=expectContentsFile,
|
||
|
)
|
||
|
|
||
|
def renameGlyphSet(self, layerName, newLayerName, defaultLayer=False):
|
||
|
"""
|
||
|
Rename a glyph set.
|
||
|
|
||
|
Note: if a GlyphSet object has already been retrieved for
|
||
|
layerName, it is up to the caller to inform that object that
|
||
|
the directory it represents has changed.
|
||
|
"""
|
||
|
if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
|
||
|
# ignore renaming glyph sets for UFO1 UFO2
|
||
|
# just write the data from the default layer
|
||
|
return
|
||
|
# the new and old names can be the same
|
||
|
# as long as the default is being switched
|
||
|
if layerName == newLayerName:
|
||
|
# if the default is off and the layer is already not the default, skip
|
||
|
if (
|
||
|
self.layerContents[layerName] != DEFAULT_GLYPHS_DIRNAME
|
||
|
and not defaultLayer
|
||
|
):
|
||
|
return
|
||
|
# if the default is on and the layer is already the default, skip
|
||
|
if self.layerContents[layerName] == DEFAULT_GLYPHS_DIRNAME and defaultLayer:
|
||
|
return
|
||
|
else:
|
||
|
# make sure the new layer name doesn't already exist
|
||
|
if newLayerName is None:
|
||
|
newLayerName = DEFAULT_LAYER_NAME
|
||
|
if newLayerName in self.layerContents:
|
||
|
raise UFOLibError("A layer named %s already exists." % newLayerName)
|
||
|
# make sure the default layer doesn't already exist
|
||
|
if defaultLayer and DEFAULT_GLYPHS_DIRNAME in self.layerContents.values():
|
||
|
raise UFOLibError("A default layer already exists.")
|
||
|
# get the paths
|
||
|
oldDirectory = self._findDirectoryForLayerName(layerName)
|
||
|
if defaultLayer:
|
||
|
newDirectory = DEFAULT_GLYPHS_DIRNAME
|
||
|
else:
|
||
|
existing = {name.lower() for name in self.layerContents.values()}
|
||
|
newDirectory = userNameToFileName(
|
||
|
newLayerName, existing=existing, prefix="glyphs."
|
||
|
)
|
||
|
# update the internal mapping
|
||
|
del self.layerContents[layerName]
|
||
|
self.layerContents[newLayerName] = newDirectory
|
||
|
# do the file system copy
|
||
|
self.fs.movedir(oldDirectory, newDirectory, create=True)
|
||
|
|
||
|
def deleteGlyphSet(self, layerName):
|
||
|
"""
|
||
|
Remove the glyph set matching layerName.
|
||
|
"""
|
||
|
if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
|
||
|
# ignore deleting glyph sets for UFO1 UFO2 as there are no layers
|
||
|
# just write the data from the default layer
|
||
|
return
|
||
|
foundDirectory = self._findDirectoryForLayerName(layerName)
|
||
|
self.removePath(foundDirectory, removeEmptyParents=False)
|
||
|
del self.layerContents[layerName]
|
||
|
|
||
|
def writeData(self, fileName, data):
|
||
|
"""
|
||
|
Write data to fileName in the 'data' directory.
|
||
|
The data must be a bytes string.
|
||
|
"""
|
||
|
self.writeBytesToPath(f"{DATA_DIRNAME}/{fsdecode(fileName)}", data)
|
||
|
|
||
|
def removeData(self, fileName):
|
||
|
"""
|
||
|
Remove the file named fileName from the data directory.
|
||
|
"""
|
||
|
self.removePath(f"{DATA_DIRNAME}/{fsdecode(fileName)}")
|
||
|
|
||
|
# /images
|
||
|
|
||
|
def writeImage(self, fileName, data, validate=None):
|
||
|
"""
|
||
|
Write data to fileName in the images directory.
|
||
|
The data must be a valid PNG.
|
||
|
"""
|
||
|
if validate is None:
|
||
|
validate = self._validate
|
||
|
if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
|
||
|
raise UFOLibError(
|
||
|
f"Images are not allowed in UFO {self._formatVersion.major}."
|
||
|
)
|
||
|
fileName = fsdecode(fileName)
|
||
|
if validate:
|
||
|
valid, error = pngValidator(data=data)
|
||
|
if not valid:
|
||
|
raise UFOLibError(error)
|
||
|
self.writeBytesToPath(f"{IMAGES_DIRNAME}/{fileName}", data)
|
||
|
|
||
|
def removeImage(self, fileName, validate=None): # XXX remove unused 'validate'?
|
||
|
"""
|
||
|
Remove the file named fileName from the
|
||
|
images directory.
|
||
|
"""
|
||
|
if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
|
||
|
raise UFOLibError(
|
||
|
f"Images are not allowed in UFO {self._formatVersion.major}."
|
||
|
)
|
||
|
self.removePath(f"{IMAGES_DIRNAME}/{fsdecode(fileName)}")
|
||
|
|
||
|
def copyImageFromReader(self, reader, sourceFileName, destFileName, validate=None):
|
||
|
"""
|
||
|
Copy the sourceFileName in the provided UFOReader to destFileName
|
||
|
in this writer. This uses the most memory efficient method possible
|
||
|
for copying the data possible.
|
||
|
"""
|
||
|
if validate is None:
|
||
|
validate = self._validate
|
||
|
if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
|
||
|
raise UFOLibError(
|
||
|
f"Images are not allowed in UFO {self._formatVersion.major}."
|
||
|
)
|
||
|
sourcePath = f"{IMAGES_DIRNAME}/{fsdecode(sourceFileName)}"
|
||
|
destPath = f"{IMAGES_DIRNAME}/{fsdecode(destFileName)}"
|
||
|
self.copyFromReader(reader, sourcePath, destPath)
|
||
|
|
||
|
def close(self):
|
||
|
if self._havePreviousFile and self._fileStructure is UFOFileStructure.ZIP:
|
||
|
# if we are updating an existing zip file, we can now compress the
|
||
|
# contents of the temporary filesystem in the destination path
|
||
|
rootDir = os.path.splitext(os.path.basename(self._path))[0] + ".ufo"
|
||
|
with fs.zipfs.ZipFS(self._path, write=True, encoding="utf-8") as destFS:
|
||
|
fs.copy.copy_fs(self.fs, destFS.makedir(rootDir))
|
||
|
super().close()
|
||
|
|
||
|
|
||
|
# 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()
|