• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1import os
2from copy import deepcopy
3from os import fsdecode
4import logging
5import zipfile
6import enum
7from collections import OrderedDict
8import fs
9import fs.base
10import fs.subfs
11import fs.errors
12import fs.copy
13import fs.osfs
14import fs.zipfs
15import fs.tempfs
16import fs.tools
17from fontTools.misc import plistlib
18from fontTools.ufoLib.validators import *
19from fontTools.ufoLib.filenames import userNameToFileName
20from fontTools.ufoLib.converters import convertUFO1OrUFO2KerningToUFO3Kerning
21from fontTools.ufoLib.errors import UFOLibError
22from fontTools.ufoLib.utils import numberTypes, _VersionTupleEnumMixin
23
24"""
25A library for importing .ufo files and their descendants.
26Refer to http://unifiedfontobject.com for the UFO specification.
27
28The UFOReader and UFOWriter classes support versions 1, 2 and 3
29of the specification.
30
31Sets that list the font info attribute names for the fontinfo.plist
32formats are available for external use. These are:
33	fontInfoAttributesVersion1
34	fontInfoAttributesVersion2
35	fontInfoAttributesVersion3
36
37A set listing the fontinfo.plist attributes that were deprecated
38in version 2 is available for external use:
39	deprecatedFontInfoAttributesVersion2
40
41Functions that do basic validation on values for fontinfo.plist
42are available for external use. These are
43	validateFontInfoVersion2ValueForAttribute
44	validateFontInfoVersion3ValueForAttribute
45
46Value conversion functions are available for converting
47fontinfo.plist values between the possible format versions.
48	convertFontInfoValueForAttributeFromVersion1ToVersion2
49	convertFontInfoValueForAttributeFromVersion2ToVersion1
50	convertFontInfoValueForAttributeFromVersion2ToVersion3
51	convertFontInfoValueForAttributeFromVersion3ToVersion2
52"""
53
54__all__ = [
55	"makeUFOPath",
56	"UFOLibError",
57	"UFOReader",
58	"UFOWriter",
59	"UFOReaderWriter",
60	"UFOFileStructure",
61	"fontInfoAttributesVersion1",
62	"fontInfoAttributesVersion2",
63	"fontInfoAttributesVersion3",
64	"deprecatedFontInfoAttributesVersion2",
65	"validateFontInfoVersion2ValueForAttribute",
66	"validateFontInfoVersion3ValueForAttribute",
67	"convertFontInfoValueForAttributeFromVersion1ToVersion2",
68	"convertFontInfoValueForAttributeFromVersion2ToVersion1"
69]
70
71__version__ = "3.0.0"
72
73
74logger = logging.getLogger(__name__)
75
76
77# ---------
78# Constants
79# ---------
80
81DEFAULT_GLYPHS_DIRNAME = "glyphs"
82DATA_DIRNAME = "data"
83IMAGES_DIRNAME = "images"
84METAINFO_FILENAME = "metainfo.plist"
85FONTINFO_FILENAME = "fontinfo.plist"
86LIB_FILENAME = "lib.plist"
87GROUPS_FILENAME = "groups.plist"
88KERNING_FILENAME = "kerning.plist"
89FEATURES_FILENAME = "features.fea"
90LAYERCONTENTS_FILENAME = "layercontents.plist"
91LAYERINFO_FILENAME = "layerinfo.plist"
92
93DEFAULT_LAYER_NAME = "public.default"
94
95
96class UFOFormatVersion(tuple, _VersionTupleEnumMixin, enum.Enum):
97	FORMAT_1_0 = (1, 0)
98	FORMAT_2_0 = (2, 0)
99	FORMAT_3_0 = (3, 0)
100
101# python 3.11 doesn't like when a mixin overrides a dunder method like __str__
102# for some reasons it keep using Enum.__str__, see
103# https://github.com/fonttools/fonttools/pull/2655
104UFOFormatVersion.__str__ = _VersionTupleEnumMixin.__str__
105
106
107class UFOFileStructure(enum.Enum):
108	ZIP = "zip"
109	PACKAGE = "package"
110
111
112# --------------
113# Shared Methods
114# --------------
115
116
117class _UFOBaseIO:
118
119	def getFileModificationTime(self, path):
120		"""
121		Returns the modification time for the file at the given path, as a
122		floating point number giving the number of seconds since the epoch.
123		The path must be relative to the UFO path.
124		Returns None if the file does not exist.
125		"""
126		try:
127			dt = self.fs.getinfo(fsdecode(path), namespaces=["details"]).modified
128		except (fs.errors.MissingInfoNamespace, fs.errors.ResourceNotFound):
129			return None
130		else:
131			return dt.timestamp()
132
133	def _getPlist(self, fileName, default=None):
134		"""
135		Read a property list relative to the UFO filesystem's root.
136		Raises UFOLibError if the file is missing and default is None,
137		otherwise default is returned.
138
139		The errors that could be raised during the reading of a plist are
140		unpredictable and/or too large to list, so, a blind try: except:
141		is done. If an exception occurs, a UFOLibError will be raised.
142		"""
143		try:
144			with self.fs.open(fileName, "rb") as f:
145				return plistlib.load(f)
146		except fs.errors.ResourceNotFound:
147			if default is None:
148				raise UFOLibError(
149					"'%s' is missing on %s. This file is required"
150					% (fileName, self.fs)
151				)
152			else:
153				return default
154		except Exception as e:
155			# TODO(anthrotype): try to narrow this down a little
156			raise UFOLibError(
157				f"'{fileName}' could not be read on {self.fs}: {e}"
158			)
159
160	def _writePlist(self, fileName, obj):
161		"""
162		Write a property list to a file relative to the UFO filesystem's root.
163
164		Do this sort of atomically, making it harder to corrupt existing files,
165		for example when plistlib encounters an error halfway during write.
166		This also checks to see if text matches the text that is already in the
167		file at path. If so, the file is not rewritten so that the modification
168		date is preserved.
169
170		The errors that could be raised during the writing of a plist are
171		unpredictable and/or too large to list, so, a blind try: except: is done.
172		If an exception occurs, a UFOLibError will be raised.
173		"""
174		if self._havePreviousFile:
175			try:
176				data = plistlib.dumps(obj)
177			except Exception as e:
178				raise UFOLibError(
179					"'%s' could not be written on %s because "
180					"the data is not properly formatted: %s"
181					% (fileName, self.fs, e)
182				)
183			if self.fs.exists(fileName) and data == self.fs.readbytes(fileName):
184				return
185			self.fs.writebytes(fileName, data)
186		else:
187			with self.fs.openbin(fileName, mode="w") as fp:
188				try:
189					plistlib.dump(obj, fp)
190				except Exception as e:
191					raise UFOLibError(
192						"'%s' could not be written on %s because "
193						"the data is not properly formatted: %s"
194						% (fileName, self.fs, e)
195					)
196
197
198# ----------
199# UFO Reader
200# ----------
201
202class UFOReader(_UFOBaseIO):
203
204	"""
205	Read the various components of the .ufo.
206
207	By default read data is validated. Set ``validate`` to
208	``False`` to not validate the data.
209	"""
210
211	def __init__(self, path, validate=True):
212		if hasattr(path, "__fspath__"):  # support os.PathLike objects
213			path = path.__fspath__()
214
215		if isinstance(path, str):
216			structure = _sniffFileStructure(path)
217			try:
218				if structure is UFOFileStructure.ZIP:
219					parentFS = fs.zipfs.ZipFS(path, write=False, encoding="utf-8")
220				else:
221					parentFS = fs.osfs.OSFS(path)
222			except fs.errors.CreateFailed as e:
223				raise UFOLibError(f"unable to open '{path}': {e}")
224
225			if structure is UFOFileStructure.ZIP:
226				# .ufoz zip files must contain a single root directory, with arbitrary
227				# name, containing all the UFO files
228				rootDirs = [
229					p.name for p in parentFS.scandir("/")
230					# exclude macOS metadata contained in zip file
231					if p.is_dir and p.name != "__MACOSX"
232				]
233				if len(rootDirs) == 1:
234					# 'ClosingSubFS' ensures that the parent zip file is closed when
235					# its root subdirectory is closed
236					self.fs = parentFS.opendir(
237						rootDirs[0], factory=fs.subfs.ClosingSubFS
238					)
239				else:
240					raise UFOLibError(
241						"Expected exactly 1 root directory, found %d" % len(rootDirs)
242					)
243			else:
244				# normal UFO 'packages' are just a single folder
245				self.fs = parentFS
246			# when passed a path string, we make sure we close the newly opened fs
247			# upon calling UFOReader.close method or context manager's __exit__
248			self._shouldClose = True
249			self._fileStructure = structure
250		elif isinstance(path, fs.base.FS):
251			filesystem = path
252			try:
253				filesystem.check()
254			except fs.errors.FilesystemClosed:
255				raise UFOLibError("the filesystem '%s' is closed" % path)
256			else:
257				self.fs = filesystem
258			try:
259				path = filesystem.getsyspath("/")
260			except fs.errors.NoSysPath:
261				# network or in-memory FS may not map to the local one
262				path = str(filesystem)
263			# when user passed an already initialized fs instance, it is her
264			# responsibility to close it, thus UFOReader.close/__exit__ are no-op
265			self._shouldClose = False
266			# default to a 'package' structure
267			self._fileStructure = UFOFileStructure.PACKAGE
268		else:
269			raise TypeError(
270				"Expected a path string or fs.base.FS object, found '%s'"
271				% type(path).__name__
272			)
273		self._path = fsdecode(path)
274		self._validate = validate
275		self._upConvertedKerningData = None
276
277		try:
278			self.readMetaInfo(validate=validate)
279		except UFOLibError:
280			self.close()
281			raise
282
283	# properties
284
285	def _get_path(self):
286		import warnings
287
288		warnings.warn(
289			"The 'path' attribute is deprecated; use the 'fs' attribute instead",
290			DeprecationWarning,
291			stacklevel=2,
292		)
293		return self._path
294
295	path = property(_get_path, doc="The path of the UFO (DEPRECATED).")
296
297	def _get_formatVersion(self):
298		import warnings
299
300		warnings.warn(
301			"The 'formatVersion' attribute is deprecated; use the 'formatVersionTuple'",
302			DeprecationWarning,
303			stacklevel=2,
304		)
305		return self._formatVersion.major
306
307	formatVersion = property(
308		_get_formatVersion,
309		doc="The (major) format version of the UFO. DEPRECATED: Use formatVersionTuple"
310	)
311
312	@property
313	def formatVersionTuple(self):
314		"""The (major, minor) format version of the UFO.
315		This is determined by reading metainfo.plist during __init__.
316		"""
317		return self._formatVersion
318
319	def _get_fileStructure(self):
320		return self._fileStructure
321
322	fileStructure = property(
323		_get_fileStructure,
324		doc=(
325			"The file structure of the UFO: "
326			"either UFOFileStructure.ZIP or UFOFileStructure.PACKAGE"
327		)
328	)
329
330	# up conversion
331
332	def _upConvertKerning(self, validate):
333		"""
334		Up convert kerning and groups in UFO 1 and 2.
335		The data will be held internally until each bit of data
336		has been retrieved. The conversion of both must be done
337		at once, so the raw data is cached and an error is raised
338		if one bit of data becomes obsolete before it is called.
339
340		``validate`` will validate the data.
341		"""
342		if self._upConvertedKerningData:
343			testKerning = self._readKerning()
344			if testKerning != self._upConvertedKerningData["originalKerning"]:
345				raise UFOLibError("The data in kerning.plist has been modified since it was converted to UFO 3 format.")
346			testGroups = self._readGroups()
347			if testGroups != self._upConvertedKerningData["originalGroups"]:
348				raise UFOLibError("The data in groups.plist has been modified since it was converted to UFO 3 format.")
349		else:
350			groups = self._readGroups()
351			if validate:
352				invalidFormatMessage = "groups.plist is not properly formatted."
353				if not isinstance(groups, dict):
354					raise UFOLibError(invalidFormatMessage)
355				for groupName, glyphList in groups.items():
356					if not isinstance(groupName, str):
357						raise UFOLibError(invalidFormatMessage)
358					elif not isinstance(glyphList, list):
359						raise UFOLibError(invalidFormatMessage)
360					for glyphName in glyphList:
361						if not isinstance(glyphName, str):
362							raise UFOLibError(invalidFormatMessage)
363			self._upConvertedKerningData = dict(
364				kerning={},
365				originalKerning=self._readKerning(),
366				groups={},
367				originalGroups=groups
368			)
369			# convert kerning and groups
370			kerning, groups, conversionMaps = convertUFO1OrUFO2KerningToUFO3Kerning(
371				self._upConvertedKerningData["originalKerning"],
372				deepcopy(self._upConvertedKerningData["originalGroups"]),
373				self.getGlyphSet()
374			)
375			# store
376			self._upConvertedKerningData["kerning"] = kerning
377			self._upConvertedKerningData["groups"] = groups
378			self._upConvertedKerningData["groupRenameMaps"] = conversionMaps
379
380	# support methods
381
382	def readBytesFromPath(self, path):
383		"""
384		Returns the bytes in the file at the given path.
385		The path must be relative to the UFO's filesystem root.
386		Returns None if the file does not exist.
387		"""
388		try:
389			return self.fs.readbytes(fsdecode(path))
390		except fs.errors.ResourceNotFound:
391			return None
392
393	def getReadFileForPath(self, path, encoding=None):
394		"""
395		Returns a file (or file-like) object for the file at the given path.
396		The path must be relative to the UFO path.
397		Returns None if the file does not exist.
398		By default the file is opened in binary mode (reads bytes).
399		If encoding is passed, the file is opened in text mode (reads str).
400
401		Note: The caller is responsible for closing the open file.
402		"""
403		path = fsdecode(path)
404		try:
405			if encoding is None:
406				return self.fs.openbin(path)
407			else:
408				return self.fs.open(path, mode="r", encoding=encoding)
409		except fs.errors.ResourceNotFound:
410			return None
411	# metainfo.plist
412
413	def _readMetaInfo(self, validate=None):
414		"""
415		Read metainfo.plist and return raw data. Only used for internal operations.
416
417		``validate`` will validate the read data, by default it is set
418		to the class's validate value, can be overridden.
419		"""
420		if validate is None:
421			validate = self._validate
422		data = self._getPlist(METAINFO_FILENAME)
423		if validate and not isinstance(data, dict):
424			raise UFOLibError("metainfo.plist is not properly formatted.")
425		try:
426			formatVersionMajor = data["formatVersion"]
427		except KeyError:
428			raise UFOLibError(
429				f"Missing required formatVersion in '{METAINFO_FILENAME}' on {self.fs}"
430			)
431		formatVersionMinor = data.setdefault("formatVersionMinor", 0)
432
433		try:
434			formatVersion = UFOFormatVersion((formatVersionMajor, formatVersionMinor))
435		except ValueError as e:
436			unsupportedMsg = (
437				f"Unsupported UFO format ({formatVersionMajor}.{formatVersionMinor}) "
438				f"in '{METAINFO_FILENAME}' on {self.fs}"
439			)
440			if validate:
441				from fontTools.ufoLib.errors import UnsupportedUFOFormat
442
443				raise UnsupportedUFOFormat(unsupportedMsg) from e
444
445			formatVersion = UFOFormatVersion.default()
446			logger.warning(
447				"%s. Assuming the latest supported version (%s). "
448				"Some data may be skipped or parsed incorrectly",
449				unsupportedMsg, formatVersion
450			)
451		data["formatVersionTuple"] = formatVersion
452		return data
453
454	def readMetaInfo(self, validate=None):
455		"""
456		Read metainfo.plist and set formatVersion. Only used for internal operations.
457
458		``validate`` will validate the read data, by default it is set
459		to the class's validate value, can be overridden.
460		"""
461		data = self._readMetaInfo(validate=validate)
462		self._formatVersion = data["formatVersionTuple"]
463
464	# groups.plist
465
466	def _readGroups(self):
467		groups = self._getPlist(GROUPS_FILENAME, {})
468		# remove any duplicate glyphs in a kerning group
469		for groupName, glyphList in groups.items():
470			if groupName.startswith(('public.kern1.', 'public.kern2.')):
471				groups[groupName] = list(OrderedDict.fromkeys(glyphList))
472		return groups
473
474	def readGroups(self, validate=None):
475		"""
476		Read groups.plist. Returns a dict.
477		``validate`` will validate the read data, by default it is set to the
478		class's validate value, can be overridden.
479		"""
480		if validate is None:
481			validate = self._validate
482		# handle up conversion
483		if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
484			self._upConvertKerning(validate)
485			groups = self._upConvertedKerningData["groups"]
486		# normal
487		else:
488			groups = self._readGroups()
489		if validate:
490			valid, message = groupsValidator(groups)
491			if not valid:
492				raise UFOLibError(message)
493		return groups
494
495	def getKerningGroupConversionRenameMaps(self, validate=None):
496		"""
497		Get maps defining the renaming that was done during any
498		needed kerning group conversion. This method returns a
499		dictionary of this form::
500
501			{
502				"side1" : {"old group name" : "new group name"},
503				"side2" : {"old group name" : "new group name"}
504			}
505
506		When no conversion has been performed, the side1 and side2
507		dictionaries will be empty.
508
509		``validate`` will validate the groups, by default it is set to the
510		class's validate value, can be overridden.
511		"""
512		if validate is None:
513			validate = self._validate
514		if self._formatVersion >= UFOFormatVersion.FORMAT_3_0:
515			return dict(side1={}, side2={})
516		# use the public group reader to force the load and
517		# conversion of the data if it hasn't happened yet.
518		self.readGroups(validate=validate)
519		return self._upConvertedKerningData["groupRenameMaps"]
520
521	# fontinfo.plist
522
523	def _readInfo(self, validate):
524		data = self._getPlist(FONTINFO_FILENAME, {})
525		if validate and not isinstance(data, dict):
526			raise UFOLibError("fontinfo.plist is not properly formatted.")
527		return data
528
529	def readInfo(self, info, validate=None):
530		"""
531		Read fontinfo.plist. It requires an object that allows
532		setting attributes with names that follow the fontinfo.plist
533		version 3 specification. This will write the attributes
534		defined in the file into the object.
535
536		``validate`` will validate the read data, by default it is set to the
537		class's validate value, can be overridden.
538		"""
539		if validate is None:
540			validate = self._validate
541		infoDict = self._readInfo(validate)
542		infoDataToSet = {}
543		# version 1
544		if self._formatVersion == UFOFormatVersion.FORMAT_1_0:
545			for attr in fontInfoAttributesVersion1:
546				value = infoDict.get(attr)
547				if value is not None:
548					infoDataToSet[attr] = value
549			infoDataToSet = _convertFontInfoDataVersion1ToVersion2(infoDataToSet)
550			infoDataToSet = _convertFontInfoDataVersion2ToVersion3(infoDataToSet)
551		# version 2
552		elif self._formatVersion == UFOFormatVersion.FORMAT_2_0:
553			for attr, dataValidationDict in list(fontInfoAttributesVersion2ValueData.items()):
554				value = infoDict.get(attr)
555				if value is None:
556					continue
557				infoDataToSet[attr] = value
558			infoDataToSet = _convertFontInfoDataVersion2ToVersion3(infoDataToSet)
559		# version 3.x
560		elif self._formatVersion.major == UFOFormatVersion.FORMAT_3_0.major:
561			for attr, dataValidationDict in list(fontInfoAttributesVersion3ValueData.items()):
562				value = infoDict.get(attr)
563				if value is None:
564					continue
565				infoDataToSet[attr] = value
566		# unsupported version
567		else:
568			raise NotImplementedError(self._formatVersion)
569		# validate data
570		if validate:
571			infoDataToSet = validateInfoVersion3Data(infoDataToSet)
572		# populate the object
573		for attr, value in list(infoDataToSet.items()):
574			try:
575				setattr(info, attr, value)
576			except AttributeError:
577				raise UFOLibError("The supplied info object does not support setting a necessary attribute (%s)." % attr)
578
579	# kerning.plist
580
581	def _readKerning(self):
582		data = self._getPlist(KERNING_FILENAME, {})
583		return data
584
585	def readKerning(self, validate=None):
586		"""
587		Read kerning.plist. Returns a dict.
588
589		``validate`` will validate the kerning data, by default it is set to the
590		class's validate value, can be overridden.
591		"""
592		if validate is None:
593			validate = self._validate
594		# handle up conversion
595		if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
596			self._upConvertKerning(validate)
597			kerningNested = self._upConvertedKerningData["kerning"]
598		# normal
599		else:
600			kerningNested = self._readKerning()
601		if validate:
602			valid, message = kerningValidator(kerningNested)
603			if not valid:
604				raise UFOLibError(message)
605		# flatten
606		kerning = {}
607		for left in kerningNested:
608			for right in kerningNested[left]:
609				value = kerningNested[left][right]
610				kerning[left, right] = value
611		return kerning
612
613	# lib.plist
614
615	def readLib(self, validate=None):
616		"""
617		Read lib.plist. Returns a dict.
618
619		``validate`` will validate the data, by default it is set to the
620		class's validate value, can be overridden.
621		"""
622		if validate is None:
623			validate = self._validate
624		data = self._getPlist(LIB_FILENAME, {})
625		if validate:
626			valid, message = fontLibValidator(data)
627			if not valid:
628				raise UFOLibError(message)
629		return data
630
631	# features.fea
632
633	def readFeatures(self):
634		"""
635		Read features.fea. Return a string.
636		The returned string is empty if the file is missing.
637		"""
638		try:
639			with self.fs.open(FEATURES_FILENAME, "r", encoding="utf-8") as f:
640				return f.read()
641		except fs.errors.ResourceNotFound:
642			return ""
643
644	# glyph sets & layers
645
646	def _readLayerContents(self, validate):
647		"""
648		Rebuild the layer contents list by checking what glyphsets
649		are available on disk.
650
651		``validate`` will validate the layer contents.
652		"""
653		if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
654			return [(DEFAULT_LAYER_NAME, DEFAULT_GLYPHS_DIRNAME)]
655		contents = self._getPlist(LAYERCONTENTS_FILENAME)
656		if validate:
657			valid, error = layerContentsValidator(contents, self.fs)
658			if not valid:
659				raise UFOLibError(error)
660		return contents
661
662	def getLayerNames(self, validate=None):
663		"""
664		Get the ordered layer names from layercontents.plist.
665
666		``validate`` will validate the data, by default it is set to the
667		class's validate value, can be overridden.
668		"""
669		if validate is None:
670			validate = self._validate
671		layerContents = self._readLayerContents(validate)
672		layerNames = [layerName for layerName, directoryName in layerContents]
673		return layerNames
674
675	def getDefaultLayerName(self, validate=None):
676		"""
677		Get the default layer name from layercontents.plist.
678
679		``validate`` will validate the data, by default it is set to the
680		class's validate value, can be overridden.
681		"""
682		if validate is None:
683			validate = self._validate
684		layerContents = self._readLayerContents(validate)
685		for layerName, layerDirectory in layerContents:
686			if layerDirectory == DEFAULT_GLYPHS_DIRNAME:
687				return layerName
688		# this will already have been raised during __init__
689		raise UFOLibError("The default layer is not defined in layercontents.plist.")
690
691	def getGlyphSet(self, layerName=None, validateRead=None, validateWrite=None):
692		"""
693		Return the GlyphSet associated with the
694		glyphs directory mapped to layerName
695		in the UFO. If layerName is not provided,
696		the name retrieved with getDefaultLayerName
697		will be used.
698
699		``validateRead`` will validate the read data, by default it is set to the
700		class's validate value, can be overridden.
701		``validateWrite`` will validate the written data, by default it is set to the
702		class's validate value, can be overridden.
703		"""
704		from fontTools.ufoLib.glifLib import GlyphSet
705
706		if validateRead is None:
707			validateRead = self._validate
708		if validateWrite is None:
709			validateWrite = self._validate
710		if layerName is None:
711			layerName = self.getDefaultLayerName(validate=validateRead)
712		directory = None
713		layerContents = self._readLayerContents(validateRead)
714		for storedLayerName, storedLayerDirectory in layerContents:
715			if layerName == storedLayerName:
716				directory = storedLayerDirectory
717				break
718		if directory is None:
719			raise UFOLibError("No glyphs directory is mapped to \"%s\"." % layerName)
720		try:
721			glyphSubFS = self.fs.opendir(directory)
722		except fs.errors.ResourceNotFound:
723			raise UFOLibError(
724				f"No '{directory}' directory for layer '{layerName}'"
725			)
726		return GlyphSet(
727			glyphSubFS,
728			ufoFormatVersion=self._formatVersion,
729			validateRead=validateRead,
730			validateWrite=validateWrite,
731			expectContentsFile=True
732		)
733
734	def getCharacterMapping(self, layerName=None, validate=None):
735		"""
736		Return a dictionary that maps unicode values (ints) to
737		lists of glyph names.
738		"""
739		if validate is None:
740			validate = self._validate
741		glyphSet = self.getGlyphSet(layerName, validateRead=validate, validateWrite=True)
742		allUnicodes = glyphSet.getUnicodes()
743		cmap = {}
744		for glyphName, unicodes in allUnicodes.items():
745			for code in unicodes:
746				if code in cmap:
747					cmap[code].append(glyphName)
748				else:
749					cmap[code] = [glyphName]
750		return cmap
751
752	# /data
753
754	def getDataDirectoryListing(self):
755		"""
756		Returns a list of all files in the data directory.
757		The returned paths will be relative to the UFO.
758		This will not list directory names, only file names.
759		Thus, empty directories will be skipped.
760		"""
761		try:
762			self._dataFS = self.fs.opendir(DATA_DIRNAME)
763		except fs.errors.ResourceNotFound:
764			return []
765		except fs.errors.DirectoryExpected:
766			raise UFOLibError("The UFO contains a \"data\" file instead of a directory.")
767		try:
768			# fs Walker.files method returns "absolute" paths (in terms of the
769			# root of the 'data' SubFS), so we strip the leading '/' to make
770			# them relative
771			return [
772				p.lstrip("/") for p in self._dataFS.walk.files()
773			]
774		except fs.errors.ResourceError:
775			return []
776
777	def getImageDirectoryListing(self, validate=None):
778		"""
779		Returns a list of all image file names in
780		the images directory. Each of the images will
781		have been verified to have the PNG signature.
782
783		``validate`` will validate the data, by default it is set to the
784		class's validate value, can be overridden.
785		"""
786		if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
787			return []
788		if validate is None:
789			validate = self._validate
790		try:
791			self._imagesFS = imagesFS = self.fs.opendir(IMAGES_DIRNAME)
792		except fs.errors.ResourceNotFound:
793			return []
794		except fs.errors.DirectoryExpected:
795			raise UFOLibError("The UFO contains an \"images\" file instead of a directory.")
796		result = []
797		for path in imagesFS.scandir("/"):
798			if path.is_dir:
799				# silently skip this as version control
800				# systems often have hidden directories
801				continue
802			if validate:
803				with imagesFS.openbin(path.name) as fp:
804					valid, error = pngValidator(fileObj=fp)
805				if valid:
806					result.append(path.name)
807			else:
808				result.append(path.name)
809		return result
810
811	def readData(self, fileName):
812		"""
813		Return bytes for the file named 'fileName' inside the 'data/' directory.
814		"""
815		fileName = fsdecode(fileName)
816		try:
817			try:
818				dataFS = self._dataFS
819			except AttributeError:
820				# in case readData is called before getDataDirectoryListing
821				dataFS = self.fs.opendir(DATA_DIRNAME)
822			data = dataFS.readbytes(fileName)
823		except fs.errors.ResourceNotFound:
824			raise UFOLibError(f"No data file named '{fileName}' on {self.fs}")
825		return data
826
827	def readImage(self, fileName, validate=None):
828		"""
829		Return image data for the file named fileName.
830
831		``validate`` will validate the data, by default it is set to the
832		class's validate value, can be overridden.
833		"""
834		if validate is None:
835			validate = self._validate
836		if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
837			raise UFOLibError(
838				f"Reading images is not allowed in UFO {self._formatVersion.major}."
839			)
840		fileName = fsdecode(fileName)
841		try:
842			try:
843				imagesFS = self._imagesFS
844			except AttributeError:
845				# in case readImage is called before getImageDirectoryListing
846				imagesFS = self.fs.opendir(IMAGES_DIRNAME)
847			data = imagesFS.readbytes(fileName)
848		except fs.errors.ResourceNotFound:
849			raise UFOLibError(f"No image file named '{fileName}' on {self.fs}")
850		if validate:
851			valid, error = pngValidator(data=data)
852			if not valid:
853				raise UFOLibError(error)
854		return data
855
856	def close(self):
857		if self._shouldClose:
858			self.fs.close()
859
860	def __enter__(self):
861		return self
862
863	def __exit__(self, exc_type, exc_value, exc_tb):
864		self.close()
865
866
867# ----------
868# UFO Writer
869# ----------
870
871class UFOWriter(UFOReader):
872
873	"""
874	Write the various components of the .ufo.
875
876	By default, the written data will be validated before writing. Set ``validate`` to
877	``False`` if you do not want to validate the data. Validation can also be overriden
878	on a per method level if desired.
879
880	The ``formatVersion`` argument allows to specify the UFO format version as a tuple
881	of integers (major, minor), or as a single integer for the major digit only (minor
882	is implied as 0). By default the latest formatVersion will be used; currently it's
883	3.0, which is equivalent to formatVersion=(3, 0).
884
885	An UnsupportedUFOFormat exception is raised if the requested UFO formatVersion is
886	not supported.
887	"""
888
889	def __init__(
890		self,
891		path,
892		formatVersion=None,
893		fileCreator="com.github.fonttools.ufoLib",
894		structure=None,
895		validate=True,
896	):
897		try:
898			formatVersion = UFOFormatVersion(formatVersion)
899		except ValueError as e:
900			from fontTools.ufoLib.errors import UnsupportedUFOFormat
901
902			raise UnsupportedUFOFormat(f"Unsupported UFO format: {formatVersion!r}") from e
903
904		if hasattr(path, "__fspath__"):  # support os.PathLike objects
905			path = path.__fspath__()
906
907		if isinstance(path, str):
908			# normalize path by removing trailing or double slashes
909			path = os.path.normpath(path)
910			havePreviousFile = os.path.exists(path)
911			if havePreviousFile:
912				# ensure we use the same structure as the destination
913				existingStructure = _sniffFileStructure(path)
914				if structure is not None:
915					try:
916						structure = UFOFileStructure(structure)
917					except ValueError:
918						raise UFOLibError(
919							"Invalid or unsupported structure: '%s'" % structure
920						)
921					if structure is not existingStructure:
922						raise UFOLibError(
923							"A UFO with a different structure (%s) already exists "
924							"at the given path: '%s'" % (existingStructure, path)
925						)
926				else:
927					structure = existingStructure
928			else:
929				# if not exists, default to 'package' structure
930				if structure is None:
931					structure = UFOFileStructure.PACKAGE
932				dirName = os.path.dirname(path)
933				if dirName and not os.path.isdir(dirName):
934					raise UFOLibError(
935						"Cannot write to '%s': directory does not exist" % path
936					)
937			if structure is UFOFileStructure.ZIP:
938				if havePreviousFile:
939					# we can't write a zip in-place, so we have to copy its
940					# contents to a temporary location and work from there, then
941					# upon closing UFOWriter we create the final zip file
942					parentFS = fs.tempfs.TempFS()
943					with fs.zipfs.ZipFS(path, encoding="utf-8") as origFS:
944						fs.copy.copy_fs(origFS, parentFS)
945					# if output path is an existing zip, we require that it contains
946					# one, and only one, root directory (with arbitrary name), in turn
947					# containing all the existing UFO contents
948					rootDirs = [
949						p.name for p in parentFS.scandir("/")
950						# exclude macOS metadata contained in zip file
951						if p.is_dir and p.name != "__MACOSX"
952					]
953					if len(rootDirs) != 1:
954						raise UFOLibError(
955							"Expected exactly 1 root directory, found %d" % len(rootDirs)
956						)
957					else:
958						# 'ClosingSubFS' ensures that the parent filesystem is closed
959						# when its root subdirectory is closed
960						self.fs = parentFS.opendir(
961							rootDirs[0], factory=fs.subfs.ClosingSubFS
962						)
963				else:
964					# if the output zip file didn't exist, we create the root folder;
965					# we name it the same as input 'path', but with '.ufo' extension
966					rootDir = os.path.splitext(os.path.basename(path))[0] + ".ufo"
967					parentFS = fs.zipfs.ZipFS(path, write=True, encoding="utf-8")
968					parentFS.makedir(rootDir)
969					self.fs = parentFS.opendir(rootDir, factory=fs.subfs.ClosingSubFS)
970			else:
971				self.fs = fs.osfs.OSFS(path, create=True)
972			self._fileStructure = structure
973			self._havePreviousFile = havePreviousFile
974			self._shouldClose = True
975		elif isinstance(path, fs.base.FS):
976			filesystem = path
977			try:
978				filesystem.check()
979			except fs.errors.FilesystemClosed:
980				raise UFOLibError("the filesystem '%s' is closed" % path)
981			else:
982				self.fs = filesystem
983			try:
984				path = filesystem.getsyspath("/")
985			except fs.errors.NoSysPath:
986				# network or in-memory FS may not map to the local one
987				path = str(filesystem)
988			# if passed an FS object, always use 'package' structure
989			if structure and structure is not UFOFileStructure.PACKAGE:
990				import warnings
991
992				warnings.warn(
993					"The 'structure' argument is not used when input is an FS object",
994					UserWarning,
995					stacklevel=2,
996				)
997			self._fileStructure = UFOFileStructure.PACKAGE
998			# if FS contains a "metainfo.plist", we consider it non-empty
999			self._havePreviousFile = filesystem.exists(METAINFO_FILENAME)
1000			# the user is responsible for closing the FS object
1001			self._shouldClose = False
1002		else:
1003			raise TypeError(
1004				"Expected a path string or fs object, found %s"
1005				% type(path).__name__
1006			)
1007
1008		# establish some basic stuff
1009		self._path = fsdecode(path)
1010		self._formatVersion = formatVersion
1011		self._fileCreator = fileCreator
1012		self._downConversionKerningData = None
1013		self._validate = validate
1014		# if the file already exists, get the format version.
1015		# this will be needed for up and down conversion.
1016		previousFormatVersion = None
1017		if self._havePreviousFile:
1018			metaInfo = self._readMetaInfo(validate=validate)
1019			previousFormatVersion = metaInfo["formatVersionTuple"]
1020			# catch down conversion
1021			if previousFormatVersion > formatVersion:
1022				from fontTools.ufoLib.errors import UnsupportedUFOFormat
1023
1024				raise UnsupportedUFOFormat(
1025					"The UFO located at this path is a higher version "
1026					f"({previousFormatVersion}) than the version ({formatVersion}) "
1027					"that is trying to be written. This is not supported."
1028				)
1029		# handle the layer contents
1030		self.layerContents = {}
1031		if previousFormatVersion is not None and previousFormatVersion.major >= 3:
1032			# already exists
1033			self.layerContents = OrderedDict(self._readLayerContents(validate))
1034		else:
1035			# previous < 3
1036			# imply the layer contents
1037			if self.fs.exists(DEFAULT_GLYPHS_DIRNAME):
1038				self.layerContents = {DEFAULT_LAYER_NAME : DEFAULT_GLYPHS_DIRNAME}
1039		# write the new metainfo
1040		self._writeMetaInfo()
1041
1042	# properties
1043
1044	def _get_fileCreator(self):
1045		return self._fileCreator
1046
1047	fileCreator = property(_get_fileCreator, doc="The file creator of the UFO. This is set into metainfo.plist during __init__.")
1048
1049	# support methods for file system interaction
1050
1051	def copyFromReader(self, reader, sourcePath, destPath):
1052		"""
1053		Copy the sourcePath in the provided UFOReader to destPath
1054		in this writer. The paths must be relative. This works with
1055		both individual files and directories.
1056		"""
1057		if not isinstance(reader, UFOReader):
1058			raise UFOLibError("The reader must be an instance of UFOReader.")
1059		sourcePath = fsdecode(sourcePath)
1060		destPath = fsdecode(destPath)
1061		if not reader.fs.exists(sourcePath):
1062			raise UFOLibError("The reader does not have data located at \"%s\"." % sourcePath)
1063		if self.fs.exists(destPath):
1064			raise UFOLibError("A file named \"%s\" already exists." % destPath)
1065		# create the destination directory if it doesn't exist
1066		self.fs.makedirs(fs.path.dirname(destPath), recreate=True)
1067		if reader.fs.isdir(sourcePath):
1068			fs.copy.copy_dir(reader.fs, sourcePath, self.fs, destPath)
1069		else:
1070			fs.copy.copy_file(reader.fs, sourcePath, self.fs, destPath)
1071
1072	def writeBytesToPath(self, path, data):
1073		"""
1074		Write bytes to a path relative to the UFO filesystem's root.
1075		If writing to an existing UFO, check to see if data matches the data
1076		that is already in the file at path; if so, the file is not rewritten
1077		so that the modification date is preserved.
1078		If needed, the directory tree for the given path will be built.
1079		"""
1080		path = fsdecode(path)
1081		if self._havePreviousFile:
1082			if self.fs.isfile(path) and data == self.fs.readbytes(path):
1083				return
1084		try:
1085			self.fs.writebytes(path, data)
1086		except fs.errors.FileExpected:
1087			raise UFOLibError("A directory exists at '%s'" % path)
1088		except fs.errors.ResourceNotFound:
1089			self.fs.makedirs(fs.path.dirname(path), recreate=True)
1090			self.fs.writebytes(path, data)
1091
1092	def getFileObjectForPath(self, path, mode="w", encoding=None):
1093		"""
1094		Returns a file (or file-like) object for the
1095		file at the given path. The path must be relative
1096		to the UFO path. Returns None if the file does
1097		not exist and the mode is "r" or "rb.
1098		An encoding may be passed if the file is opened in text mode.
1099
1100		Note: The caller is responsible for closing the open file.
1101		"""
1102		path = fsdecode(path)
1103		try:
1104			return self.fs.open(path, mode=mode, encoding=encoding)
1105		except fs.errors.ResourceNotFound as e:
1106			m = mode[0]
1107			if m == "r":
1108				# XXX I think we should just let it raise. The docstring,
1109				# however, says that this returns None if mode is 'r'
1110				return None
1111			elif m == "w" or m == "a" or m == "x":
1112				self.fs.makedirs(fs.path.dirname(path), recreate=True)
1113				return self.fs.open(path, mode=mode, encoding=encoding)
1114		except fs.errors.ResourceError as e:
1115			return UFOLibError(
1116				f"unable to open '{path}' on {self.fs}: {e}"
1117			)
1118
1119	def removePath(self, path, force=False, removeEmptyParents=True):
1120		"""
1121		Remove the file (or directory) at path. The path
1122		must be relative to the UFO.
1123		Raises UFOLibError if the path doesn't exist.
1124		If force=True, ignore non-existent paths.
1125		If the directory where 'path' is located becomes empty, it will
1126		be automatically removed, unless 'removeEmptyParents' is False.
1127		"""
1128		path = fsdecode(path)
1129		try:
1130			self.fs.remove(path)
1131		except fs.errors.FileExpected:
1132			self.fs.removetree(path)
1133		except fs.errors.ResourceNotFound:
1134			if not force:
1135				raise UFOLibError(
1136					f"'{path}' does not exist on {self.fs}"
1137				)
1138		if removeEmptyParents:
1139			parent = fs.path.dirname(path)
1140			if parent:
1141				fs.tools.remove_empty(self.fs, parent)
1142
1143	# alias kept for backward compatibility with old API
1144	removeFileForPath = removePath
1145
1146	# UFO mod time
1147
1148	def setModificationTime(self):
1149		"""
1150		Set the UFO modification time to the current time.
1151		This is never called automatically. It is up to the
1152		caller to call this when finished working on the UFO.
1153		"""
1154		path = self._path
1155		if path is not None and os.path.exists(path):
1156			try:
1157				# this may fail on some filesystems (e.g. SMB servers)
1158				os.utime(path, None)
1159			except OSError as e:
1160				logger.warning("Failed to set modified time: %s", e)
1161
1162	# metainfo.plist
1163
1164	def _writeMetaInfo(self):
1165		metaInfo = dict(
1166			creator=self._fileCreator,
1167			formatVersion=self._formatVersion.major,
1168		)
1169		if self._formatVersion.minor != 0:
1170			metaInfo["formatVersionMinor"] = self._formatVersion.minor
1171		self._writePlist(METAINFO_FILENAME, metaInfo)
1172
1173	# groups.plist
1174
1175	def setKerningGroupConversionRenameMaps(self, maps):
1176		"""
1177		Set maps defining the renaming that should be done
1178		when writing groups and kerning in UFO 1 and UFO 2.
1179		This will effectively undo the conversion done when
1180		UFOReader reads this data. The dictionary should have
1181		this form::
1182
1183			{
1184				"side1" : {"group name to use when writing" : "group name in data"},
1185				"side2" : {"group name to use when writing" : "group name in data"}
1186			}
1187
1188		This is the same form returned by UFOReader's
1189		getKerningGroupConversionRenameMaps method.
1190		"""
1191		if self._formatVersion >= UFOFormatVersion.FORMAT_3_0:
1192			return # XXX raise an error here
1193		# flip the dictionaries
1194		remap = {}
1195		for side in ("side1", "side2"):
1196			for writeName, dataName in list(maps[side].items()):
1197				remap[dataName] = writeName
1198		self._downConversionKerningData = dict(groupRenameMap=remap)
1199
1200	def writeGroups(self, groups, validate=None):
1201		"""
1202		Write groups.plist. This method requires a
1203		dict of glyph groups as an argument.
1204
1205		``validate`` will validate the data, by default it is set to the
1206		class's validate value, can be overridden.
1207		"""
1208		if validate is None:
1209			validate = self._validate
1210		# validate the data structure
1211		if validate:
1212			valid, message = groupsValidator(groups)
1213			if not valid:
1214				raise UFOLibError(message)
1215		# down convert
1216		if (
1217			self._formatVersion < UFOFormatVersion.FORMAT_3_0
1218			and self._downConversionKerningData is not None
1219		):
1220			remap = self._downConversionKerningData["groupRenameMap"]
1221			remappedGroups = {}
1222			# there are some edge cases here that are ignored:
1223			# 1. if a group is being renamed to a name that
1224			#    already exists, the existing group is always
1225			#    overwritten. (this is why there are two loops
1226			#    below.) there doesn't seem to be a logical
1227			#    solution to groups mismatching and overwriting
1228			#    with the specifiecd group seems like a better
1229			#    solution than throwing an error.
1230			# 2. if side 1 and side 2 groups are being renamed
1231			#    to the same group name there is no check to
1232			#    ensure that the contents are identical. that
1233			#    is left up to the caller.
1234			for name, contents in list(groups.items()):
1235				if name in remap:
1236					continue
1237				remappedGroups[name] = contents
1238			for name, contents in list(groups.items()):
1239				if name not in remap:
1240					continue
1241				name = remap[name]
1242				remappedGroups[name] = contents
1243			groups = remappedGroups
1244		# pack and write
1245		groupsNew = {}
1246		for key, value in groups.items():
1247			groupsNew[key] = list(value)
1248		if groupsNew:
1249			self._writePlist(GROUPS_FILENAME, groupsNew)
1250		elif self._havePreviousFile:
1251			self.removePath(GROUPS_FILENAME, force=True, removeEmptyParents=False)
1252
1253	# fontinfo.plist
1254
1255	def writeInfo(self, info, validate=None):
1256		"""
1257		Write info.plist. This method requires an object
1258		that supports getting attributes that follow the
1259		fontinfo.plist version 2 specification. Attributes
1260		will be taken from the given object and written
1261		into the file.
1262
1263		``validate`` will validate the data, by default it is set to the
1264		class's validate value, can be overridden.
1265		"""
1266		if validate is None:
1267			validate = self._validate
1268		# gather version 3 data
1269		infoData = {}
1270		for attr in list(fontInfoAttributesVersion3ValueData.keys()):
1271			if hasattr(info, attr):
1272				try:
1273					value = getattr(info, attr)
1274				except AttributeError:
1275					raise UFOLibError("The supplied info object does not support getting a necessary attribute (%s)." % attr)
1276				if value is None:
1277					continue
1278				infoData[attr] = value
1279		# down convert data if necessary and validate
1280		if self._formatVersion == UFOFormatVersion.FORMAT_3_0:
1281			if validate:
1282				infoData = validateInfoVersion3Data(infoData)
1283		elif self._formatVersion == UFOFormatVersion.FORMAT_2_0:
1284			infoData = _convertFontInfoDataVersion3ToVersion2(infoData)
1285			if validate:
1286				infoData = validateInfoVersion2Data(infoData)
1287		elif self._formatVersion == UFOFormatVersion.FORMAT_1_0:
1288			infoData = _convertFontInfoDataVersion3ToVersion2(infoData)
1289			if validate:
1290				infoData = validateInfoVersion2Data(infoData)
1291			infoData = _convertFontInfoDataVersion2ToVersion1(infoData)
1292		# write file if there is anything to write
1293		if infoData:
1294			self._writePlist(FONTINFO_FILENAME, infoData)
1295
1296	# kerning.plist
1297
1298	def writeKerning(self, kerning, validate=None):
1299		"""
1300		Write kerning.plist. This method requires a
1301		dict of kerning pairs as an argument.
1302
1303		This performs basic structural validation of the kerning,
1304		but it does not check for compliance with the spec in
1305		regards to conflicting pairs. The assumption is that the
1306		kerning data being passed is standards compliant.
1307
1308		``validate`` will validate the data, by default it is set to the
1309		class's validate value, can be overridden.
1310		"""
1311		if validate is None:
1312			validate = self._validate
1313		# validate the data structure
1314		if validate:
1315			invalidFormatMessage = "The kerning is not properly formatted."
1316			if not isDictEnough(kerning):
1317				raise UFOLibError(invalidFormatMessage)
1318			for pair, value in list(kerning.items()):
1319				if not isinstance(pair, (list, tuple)):
1320					raise UFOLibError(invalidFormatMessage)
1321				if not len(pair) == 2:
1322					raise UFOLibError(invalidFormatMessage)
1323				if not isinstance(pair[0], str):
1324					raise UFOLibError(invalidFormatMessage)
1325				if not isinstance(pair[1], str):
1326					raise UFOLibError(invalidFormatMessage)
1327				if not isinstance(value, numberTypes):
1328					raise UFOLibError(invalidFormatMessage)
1329		# down convert
1330		if (
1331			self._formatVersion < UFOFormatVersion.FORMAT_3_0
1332			and self._downConversionKerningData is not None
1333		):
1334			remap = self._downConversionKerningData["groupRenameMap"]
1335			remappedKerning = {}
1336			for (side1, side2), value in list(kerning.items()):
1337				side1 = remap.get(side1, side1)
1338				side2 = remap.get(side2, side2)
1339				remappedKerning[side1, side2] = value
1340			kerning = remappedKerning
1341		# pack and write
1342		kerningDict = {}
1343		for left, right in kerning.keys():
1344			value = kerning[left, right]
1345			if left not in kerningDict:
1346				kerningDict[left] = {}
1347			kerningDict[left][right] = value
1348		if kerningDict:
1349			self._writePlist(KERNING_FILENAME, kerningDict)
1350		elif self._havePreviousFile:
1351			self.removePath(KERNING_FILENAME, force=True, removeEmptyParents=False)
1352
1353	# lib.plist
1354
1355	def writeLib(self, libDict, validate=None):
1356		"""
1357		Write lib.plist. This method requires a
1358		lib dict as an argument.
1359
1360		``validate`` will validate the data, by default it is set to the
1361		class's validate value, can be overridden.
1362		"""
1363		if validate is None:
1364			validate = self._validate
1365		if validate:
1366			valid, message = fontLibValidator(libDict)
1367			if not valid:
1368				raise UFOLibError(message)
1369		if libDict:
1370			self._writePlist(LIB_FILENAME, libDict)
1371		elif self._havePreviousFile:
1372			self.removePath(LIB_FILENAME, force=True, removeEmptyParents=False)
1373
1374	# features.fea
1375
1376	def writeFeatures(self, features, validate=None):
1377		"""
1378		Write features.fea. This method requires a
1379		features string as an argument.
1380		"""
1381		if validate is None:
1382			validate = self._validate
1383		if self._formatVersion == UFOFormatVersion.FORMAT_1_0:
1384			raise UFOLibError("features.fea is not allowed in UFO Format Version 1.")
1385		if validate:
1386			if not isinstance(features, str):
1387				raise UFOLibError("The features are not text.")
1388		if features:
1389			self.writeBytesToPath(FEATURES_FILENAME, features.encode("utf8"))
1390		elif self._havePreviousFile:
1391			self.removePath(FEATURES_FILENAME, force=True, removeEmptyParents=False)
1392
1393	# glyph sets & layers
1394
1395	def writeLayerContents(self, layerOrder=None, validate=None):
1396		"""
1397		Write the layercontents.plist file. This method  *must* be called
1398		after all glyph sets have been written.
1399		"""
1400		if validate is None:
1401			validate = self._validate
1402		if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
1403			return
1404		if layerOrder is not None:
1405			newOrder = []
1406			for layerName in layerOrder:
1407				if layerName is None:
1408					layerName = DEFAULT_LAYER_NAME
1409				newOrder.append(layerName)
1410			layerOrder = newOrder
1411		else:
1412			layerOrder = list(self.layerContents.keys())
1413		if validate and set(layerOrder) != set(self.layerContents.keys()):
1414			raise UFOLibError("The layer order content does not match the glyph sets that have been created.")
1415		layerContents = [(layerName, self.layerContents[layerName]) for layerName in layerOrder]
1416		self._writePlist(LAYERCONTENTS_FILENAME, layerContents)
1417
1418	def _findDirectoryForLayerName(self, layerName):
1419		foundDirectory = None
1420		for existingLayerName, directoryName in list(self.layerContents.items()):
1421			if layerName is None and directoryName == DEFAULT_GLYPHS_DIRNAME:
1422				foundDirectory = directoryName
1423				break
1424			elif existingLayerName == layerName:
1425				foundDirectory = directoryName
1426				break
1427		if not foundDirectory:
1428			raise UFOLibError("Could not locate a glyph set directory for the layer named %s." % layerName)
1429		return foundDirectory
1430
1431	def getGlyphSet(
1432		self,
1433		layerName=None,
1434		defaultLayer=True,
1435		glyphNameToFileNameFunc=None,
1436		validateRead=None,
1437		validateWrite=None,
1438		expectContentsFile=False,
1439	):
1440		"""
1441		Return the GlyphSet object associated with the
1442		appropriate glyph directory in the .ufo.
1443		If layerName is None, the default glyph set
1444		will be used. The defaultLayer flag indictes
1445		that the layer should be saved into the default
1446		glyphs directory.
1447
1448		``validateRead`` will validate the read data, by default it is set to the
1449		class's validate value, can be overridden.
1450		``validateWrte`` will validate the written data, by default it is set to the
1451		class's validate value, can be overridden.
1452		``expectContentsFile`` will raise a GlifLibError if a contents.plist file is
1453		not found on the glyph set file system. This should be set to ``True`` if you
1454		are reading an existing UFO and ``False`` if you use ``getGlyphSet`` to create
1455		a fresh	glyph set.
1456		"""
1457		if validateRead is None:
1458			validateRead = self._validate
1459		if validateWrite is None:
1460			validateWrite = self._validate
1461		# only default can be written in < 3
1462		if (
1463			self._formatVersion < UFOFormatVersion.FORMAT_3_0
1464			and (not defaultLayer or layerName is not None)
1465		):
1466			raise UFOLibError(
1467				f"Only the default layer can be writen in UFO {self._formatVersion.major}."
1468			)
1469		# locate a layer name when None has been given
1470		if layerName is None and defaultLayer:
1471			for existingLayerName, directory in self.layerContents.items():
1472				if directory == DEFAULT_GLYPHS_DIRNAME:
1473					layerName = existingLayerName
1474			if layerName is None:
1475				layerName = DEFAULT_LAYER_NAME
1476		elif layerName is None and not defaultLayer:
1477			raise UFOLibError("A layer name must be provided for non-default layers.")
1478		# move along to format specific writing
1479		if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
1480			return self._getDefaultGlyphSet(
1481				validateRead,
1482				validateWrite,
1483				glyphNameToFileNameFunc=glyphNameToFileNameFunc,
1484				expectContentsFile=expectContentsFile
1485			)
1486		elif self._formatVersion.major == UFOFormatVersion.FORMAT_3_0.major:
1487			return self._getGlyphSetFormatVersion3(
1488				validateRead,
1489				validateWrite,
1490				layerName=layerName,
1491				defaultLayer=defaultLayer,
1492				glyphNameToFileNameFunc=glyphNameToFileNameFunc,
1493				expectContentsFile=expectContentsFile,
1494			)
1495		else:
1496			raise NotImplementedError(self._formatVersion)
1497
1498	def _getDefaultGlyphSet(
1499		self,
1500		validateRead,
1501		validateWrite,
1502		glyphNameToFileNameFunc=None,
1503		expectContentsFile=False,
1504	):
1505		from fontTools.ufoLib.glifLib import GlyphSet
1506
1507		glyphSubFS = self.fs.makedir(DEFAULT_GLYPHS_DIRNAME, recreate=True)
1508		return GlyphSet(
1509			glyphSubFS,
1510			glyphNameToFileNameFunc=glyphNameToFileNameFunc,
1511			ufoFormatVersion=self._formatVersion,
1512			validateRead=validateRead,
1513			validateWrite=validateWrite,
1514			expectContentsFile=expectContentsFile,
1515		)
1516
1517	def _getGlyphSetFormatVersion3(
1518		self,
1519		validateRead,
1520		validateWrite,
1521		layerName=None,
1522		defaultLayer=True,
1523		glyphNameToFileNameFunc=None,
1524		expectContentsFile=False,
1525	):
1526		from fontTools.ufoLib.glifLib import GlyphSet
1527
1528		# if the default flag is on, make sure that the default in the file
1529		# matches the default being written. also make sure that this layer
1530		# name is not already linked to a non-default layer.
1531		if defaultLayer:
1532			for existingLayerName, directory in self.layerContents.items():
1533				if directory == DEFAULT_GLYPHS_DIRNAME:
1534					if existingLayerName != layerName:
1535						raise UFOLibError(
1536							"Another layer ('%s') is already mapped to the default directory."
1537							% existingLayerName
1538						)
1539				elif existingLayerName == layerName:
1540					raise UFOLibError("The layer name is already mapped to a non-default layer.")
1541		# get an existing directory name
1542		if layerName in self.layerContents:
1543			directory = self.layerContents[layerName]
1544		# get a  new directory name
1545		else:
1546			if defaultLayer:
1547				directory = DEFAULT_GLYPHS_DIRNAME
1548			else:
1549				# not caching this could be slightly expensive,
1550				# but caching it will be cumbersome
1551				existing = {d.lower() for d in self.layerContents.values()}
1552				directory = userNameToFileName(layerName, existing=existing, prefix="glyphs.")
1553		# make the directory
1554		glyphSubFS = self.fs.makedir(directory, recreate=True)
1555		# store the mapping
1556		self.layerContents[layerName] = directory
1557		# load the glyph set
1558		return GlyphSet(
1559			glyphSubFS,
1560			glyphNameToFileNameFunc=glyphNameToFileNameFunc,
1561			ufoFormatVersion=self._formatVersion,
1562			validateRead=validateRead,
1563			validateWrite=validateWrite,
1564			expectContentsFile=expectContentsFile,
1565		)
1566
1567	def renameGlyphSet(self, layerName, newLayerName, defaultLayer=False):
1568		"""
1569		Rename a glyph set.
1570
1571		Note: if a GlyphSet object has already been retrieved for
1572		layerName, it is up to the caller to inform that object that
1573		the directory it represents has changed.
1574		"""
1575		if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
1576			# ignore renaming glyph sets for UFO1 UFO2
1577			# just write the data from the default layer
1578			return
1579		# the new and old names can be the same
1580		# as long as the default is being switched
1581		if layerName == newLayerName:
1582			# if the default is off and the layer is already not the default, skip
1583			if self.layerContents[layerName] != DEFAULT_GLYPHS_DIRNAME and not defaultLayer:
1584				return
1585			# if the default is on and the layer is already the default, skip
1586			if self.layerContents[layerName] == DEFAULT_GLYPHS_DIRNAME and defaultLayer:
1587				return
1588		else:
1589			# make sure the new layer name doesn't already exist
1590			if newLayerName is None:
1591				newLayerName = DEFAULT_LAYER_NAME
1592			if newLayerName in self.layerContents:
1593				raise UFOLibError("A layer named %s already exists." % newLayerName)
1594			# make sure the default layer doesn't already exist
1595			if defaultLayer and DEFAULT_GLYPHS_DIRNAME in self.layerContents.values():
1596				raise UFOLibError("A default layer already exists.")
1597		# get the paths
1598		oldDirectory = self._findDirectoryForLayerName(layerName)
1599		if defaultLayer:
1600			newDirectory = DEFAULT_GLYPHS_DIRNAME
1601		else:
1602			existing = {name.lower() for name in self.layerContents.values()}
1603			newDirectory = userNameToFileName(newLayerName, existing=existing, prefix="glyphs.")
1604		# update the internal mapping
1605		del self.layerContents[layerName]
1606		self.layerContents[newLayerName] = newDirectory
1607		# do the file system copy
1608		self.fs.movedir(oldDirectory, newDirectory, create=True)
1609
1610	def deleteGlyphSet(self, layerName):
1611		"""
1612		Remove the glyph set matching layerName.
1613		"""
1614		if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
1615			# ignore deleting glyph sets for UFO1 UFO2 as there are no layers
1616			# just write the data from the default layer
1617			return
1618		foundDirectory = self._findDirectoryForLayerName(layerName)
1619		self.removePath(foundDirectory, removeEmptyParents=False)
1620		del self.layerContents[layerName]
1621
1622	def writeData(self, fileName, data):
1623		"""
1624		Write data to fileName in the 'data' directory.
1625		The data must be a bytes string.
1626		"""
1627		self.writeBytesToPath(f"{DATA_DIRNAME}/{fsdecode(fileName)}", data)
1628
1629	def removeData(self, fileName):
1630		"""
1631		Remove the file named fileName from the data directory.
1632		"""
1633		self.removePath(f"{DATA_DIRNAME}/{fsdecode(fileName)}")
1634
1635	# /images
1636
1637	def writeImage(self, fileName, data, validate=None):
1638		"""
1639		Write data to fileName in the images directory.
1640		The data must be a valid PNG.
1641		"""
1642		if validate is None:
1643			validate = self._validate
1644		if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
1645			raise UFOLibError(
1646				f"Images are not allowed in UFO {self._formatVersion.major}."
1647			)
1648		fileName = fsdecode(fileName)
1649		if validate:
1650			valid, error = pngValidator(data=data)
1651			if not valid:
1652				raise UFOLibError(error)
1653		self.writeBytesToPath(f"{IMAGES_DIRNAME}/{fileName}", data)
1654
1655	def removeImage(self, fileName, validate=None):  # XXX remove unused 'validate'?
1656		"""
1657		Remove the file named fileName from the
1658		images directory.
1659		"""
1660		if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
1661			raise UFOLibError(
1662				f"Images are not allowed in UFO {self._formatVersion.major}."
1663			)
1664		self.removePath(f"{IMAGES_DIRNAME}/{fsdecode(fileName)}")
1665
1666	def copyImageFromReader(self, reader, sourceFileName, destFileName, validate=None):
1667		"""
1668		Copy the sourceFileName in the provided UFOReader to destFileName
1669		in this writer. This uses the most memory efficient method possible
1670		for copying the data possible.
1671		"""
1672		if validate is None:
1673			validate = self._validate
1674		if self._formatVersion < UFOFormatVersion.FORMAT_3_0:
1675			raise UFOLibError(
1676				f"Images are not allowed in UFO {self._formatVersion.major}."
1677			)
1678		sourcePath = f"{IMAGES_DIRNAME}/{fsdecode(sourceFileName)}"
1679		destPath = f"{IMAGES_DIRNAME}/{fsdecode(destFileName)}"
1680		self.copyFromReader(reader, sourcePath, destPath)
1681
1682	def close(self):
1683		if self._havePreviousFile and self._fileStructure is UFOFileStructure.ZIP:
1684			# if we are updating an existing zip file, we can now compress the
1685			# contents of the temporary filesystem in the destination path
1686			rootDir = os.path.splitext(os.path.basename(self._path))[0] + ".ufo"
1687			with fs.zipfs.ZipFS(self._path, write=True, encoding="utf-8") as destFS:
1688				fs.copy.copy_fs(self.fs, destFS.makedir(rootDir))
1689		super().close()
1690
1691
1692# just an alias, makes it more explicit
1693UFOReaderWriter = UFOWriter
1694
1695
1696# ----------------
1697# Helper Functions
1698# ----------------
1699
1700
1701def _sniffFileStructure(ufo_path):
1702	"""Return UFOFileStructure.ZIP if the UFO at path 'ufo_path' (str)
1703	is a zip file, else return UFOFileStructure.PACKAGE if 'ufo_path' is a
1704	directory.
1705	Raise UFOLibError if it is a file with unknown structure, or if the path
1706	does not exist.
1707	"""
1708	if zipfile.is_zipfile(ufo_path):
1709		return UFOFileStructure.ZIP
1710	elif os.path.isdir(ufo_path):
1711		return UFOFileStructure.PACKAGE
1712	elif os.path.isfile(ufo_path):
1713		raise UFOLibError(
1714			"The specified UFO does not have a known structure: '%s'" % ufo_path
1715		)
1716	else:
1717		raise UFOLibError("No such file or directory: '%s'" % ufo_path)
1718
1719
1720def makeUFOPath(path):
1721	"""
1722	Return a .ufo pathname.
1723
1724	>>> makeUFOPath("directory/something.ext") == (
1725	... 	os.path.join('directory', 'something.ufo'))
1726	True
1727	>>> makeUFOPath("directory/something.another.thing.ext") == (
1728	... 	os.path.join('directory', 'something.another.thing.ufo'))
1729	True
1730	"""
1731	dir, name = os.path.split(path)
1732	name = ".".join([".".join(name.split(".")[:-1]), "ufo"])
1733	return os.path.join(dir, name)
1734
1735# ----------------------
1736# fontinfo.plist Support
1737# ----------------------
1738
1739# Version Validators
1740
1741# There is no version 1 validator and there shouldn't be.
1742# The version 1 spec was very loose and there were numerous
1743# cases of invalid values.
1744
1745def validateFontInfoVersion2ValueForAttribute(attr, value):
1746	"""
1747	This performs very basic validation of the value for attribute
1748	following the UFO 2 fontinfo.plist specification. The results
1749	of this should not be interpretted as *correct* for the font
1750	that they are part of. This merely indicates that the value
1751	is of the proper type and, where the specification defines
1752	a set range of possible values for an attribute, that the
1753	value is in the accepted range.
1754	"""
1755	dataValidationDict = fontInfoAttributesVersion2ValueData[attr]
1756	valueType = dataValidationDict.get("type")
1757	validator = dataValidationDict.get("valueValidator")
1758	valueOptions = dataValidationDict.get("valueOptions")
1759	# have specific options for the validator
1760	if valueOptions is not None:
1761		isValidValue = validator(value, valueOptions)
1762	# no specific options
1763	else:
1764		if validator == genericTypeValidator:
1765			isValidValue = validator(value, valueType)
1766		else:
1767			isValidValue = validator(value)
1768	return isValidValue
1769
1770def validateInfoVersion2Data(infoData):
1771	"""
1772	This performs very basic validation of the value for infoData
1773	following the UFO 2 fontinfo.plist specification. The results
1774	of this should not be interpretted as *correct* for the font
1775	that they are part of. This merely indicates that the values
1776	are of the proper type and, where the specification defines
1777	a set range of possible values for an attribute, that the
1778	value is in the accepted range.
1779	"""
1780	validInfoData = {}
1781	for attr, value in list(infoData.items()):
1782		isValidValue = validateFontInfoVersion2ValueForAttribute(attr, value)
1783		if not isValidValue:
1784			raise UFOLibError(f"Invalid value for attribute {attr} ({value!r}).")
1785		else:
1786			validInfoData[attr] = value
1787	return validInfoData
1788
1789def validateFontInfoVersion3ValueForAttribute(attr, value):
1790	"""
1791	This performs very basic validation of the value for attribute
1792	following the UFO 3 fontinfo.plist specification. The results
1793	of this should not be interpretted as *correct* for the font
1794	that they are part of. This merely indicates that the value
1795	is of the proper type and, where the specification defines
1796	a set range of possible values for an attribute, that the
1797	value is in the accepted range.
1798	"""
1799	dataValidationDict = fontInfoAttributesVersion3ValueData[attr]
1800	valueType = dataValidationDict.get("type")
1801	validator = dataValidationDict.get("valueValidator")
1802	valueOptions = dataValidationDict.get("valueOptions")
1803	# have specific options for the validator
1804	if valueOptions is not None:
1805		isValidValue = validator(value, valueOptions)
1806	# no specific options
1807	else:
1808		if validator == genericTypeValidator:
1809			isValidValue = validator(value, valueType)
1810		else:
1811			isValidValue = validator(value)
1812	return isValidValue
1813
1814def validateInfoVersion3Data(infoData):
1815	"""
1816	This performs very basic validation of the value for infoData
1817	following the UFO 3 fontinfo.plist specification. The results
1818	of this should not be interpretted as *correct* for the font
1819	that they are part of. This merely indicates that the values
1820	are of the proper type and, where the specification defines
1821	a set range of possible values for an attribute, that the
1822	value is in the accepted range.
1823	"""
1824	validInfoData = {}
1825	for attr, value in list(infoData.items()):
1826		isValidValue = validateFontInfoVersion3ValueForAttribute(attr, value)
1827		if not isValidValue:
1828			raise UFOLibError(f"Invalid value for attribute {attr} ({value!r}).")
1829		else:
1830			validInfoData[attr] = value
1831	return validInfoData
1832
1833# Value Options
1834
1835fontInfoOpenTypeHeadFlagsOptions = list(range(0, 15))
1836fontInfoOpenTypeOS2SelectionOptions = [1, 2, 3, 4, 7, 8, 9]
1837fontInfoOpenTypeOS2UnicodeRangesOptions = list(range(0, 128))
1838fontInfoOpenTypeOS2CodePageRangesOptions = list(range(0, 64))
1839fontInfoOpenTypeOS2TypeOptions = [0, 1, 2, 3, 8, 9]
1840
1841# Version Attribute Definitions
1842# This defines the attributes, types and, in some
1843# cases the possible values, that can exist is
1844# fontinfo.plist.
1845
1846fontInfoAttributesVersion1 = {
1847	"familyName",
1848	"styleName",
1849	"fullName",
1850	"fontName",
1851	"menuName",
1852	"fontStyle",
1853	"note",
1854	"versionMajor",
1855	"versionMinor",
1856	"year",
1857	"copyright",
1858	"notice",
1859	"trademark",
1860	"license",
1861	"licenseURL",
1862	"createdBy",
1863	"designer",
1864	"designerURL",
1865	"vendorURL",
1866	"unitsPerEm",
1867	"ascender",
1868	"descender",
1869	"capHeight",
1870	"xHeight",
1871	"defaultWidth",
1872	"slantAngle",
1873	"italicAngle",
1874	"widthName",
1875	"weightName",
1876	"weightValue",
1877	"fondName",
1878	"otFamilyName",
1879	"otStyleName",
1880	"otMacName",
1881	"msCharSet",
1882	"fondID",
1883	"uniqueID",
1884	"ttVendor",
1885	"ttUniqueID",
1886	"ttVersion",
1887}
1888
1889fontInfoAttributesVersion2ValueData = {
1890	"familyName"							: dict(type=str),
1891	"styleName"								: dict(type=str),
1892	"styleMapFamilyName"					: dict(type=str),
1893	"styleMapStyleName"						: dict(type=str, valueValidator=fontInfoStyleMapStyleNameValidator),
1894	"versionMajor"							: dict(type=int),
1895	"versionMinor"							: dict(type=int),
1896	"year"									: dict(type=int),
1897	"copyright"								: dict(type=str),
1898	"trademark"								: dict(type=str),
1899	"unitsPerEm"							: dict(type=(int, float)),
1900	"descender"								: dict(type=(int, float)),
1901	"xHeight"								: dict(type=(int, float)),
1902	"capHeight"								: dict(type=(int, float)),
1903	"ascender"								: dict(type=(int, float)),
1904	"italicAngle"							: dict(type=(float, int)),
1905	"note"									: dict(type=str),
1906	"openTypeHeadCreated"					: dict(type=str, valueValidator=fontInfoOpenTypeHeadCreatedValidator),
1907	"openTypeHeadLowestRecPPEM"				: dict(type=(int, float)),
1908	"openTypeHeadFlags"						: dict(type="integerList", valueValidator=genericIntListValidator, valueOptions=fontInfoOpenTypeHeadFlagsOptions),
1909	"openTypeHheaAscender"					: dict(type=(int, float)),
1910	"openTypeHheaDescender"					: dict(type=(int, float)),
1911	"openTypeHheaLineGap"					: dict(type=(int, float)),
1912	"openTypeHheaCaretSlopeRise"			: dict(type=int),
1913	"openTypeHheaCaretSlopeRun"				: dict(type=int),
1914	"openTypeHheaCaretOffset"				: dict(type=(int, float)),
1915	"openTypeNameDesigner"					: dict(type=str),
1916	"openTypeNameDesignerURL"				: dict(type=str),
1917	"openTypeNameManufacturer"				: dict(type=str),
1918	"openTypeNameManufacturerURL"			: dict(type=str),
1919	"openTypeNameLicense"					: dict(type=str),
1920	"openTypeNameLicenseURL"				: dict(type=str),
1921	"openTypeNameVersion"					: dict(type=str),
1922	"openTypeNameUniqueID"					: dict(type=str),
1923	"openTypeNameDescription"				: dict(type=str),
1924	"openTypeNamePreferredFamilyName"		: dict(type=str),
1925	"openTypeNamePreferredSubfamilyName"	: dict(type=str),
1926	"openTypeNameCompatibleFullName"		: dict(type=str),
1927	"openTypeNameSampleText"				: dict(type=str),
1928	"openTypeNameWWSFamilyName"				: dict(type=str),
1929	"openTypeNameWWSSubfamilyName"			: dict(type=str),
1930	"openTypeOS2WidthClass"					: dict(type=int, valueValidator=fontInfoOpenTypeOS2WidthClassValidator),
1931	"openTypeOS2WeightClass"				: dict(type=int, valueValidator=fontInfoOpenTypeOS2WeightClassValidator),
1932	"openTypeOS2Selection"					: dict(type="integerList", valueValidator=genericIntListValidator, valueOptions=fontInfoOpenTypeOS2SelectionOptions),
1933	"openTypeOS2VendorID"					: dict(type=str),
1934	"openTypeOS2Panose"						: dict(type="integerList", valueValidator=fontInfoVersion2OpenTypeOS2PanoseValidator),
1935	"openTypeOS2FamilyClass"				: dict(type="integerList", valueValidator=fontInfoOpenTypeOS2FamilyClassValidator),
1936	"openTypeOS2UnicodeRanges"				: dict(type="integerList", valueValidator=genericIntListValidator, valueOptions=fontInfoOpenTypeOS2UnicodeRangesOptions),
1937	"openTypeOS2CodePageRanges"				: dict(type="integerList", valueValidator=genericIntListValidator, valueOptions=fontInfoOpenTypeOS2CodePageRangesOptions),
1938	"openTypeOS2TypoAscender"				: dict(type=(int, float)),
1939	"openTypeOS2TypoDescender"				: dict(type=(int, float)),
1940	"openTypeOS2TypoLineGap"				: dict(type=(int, float)),
1941	"openTypeOS2WinAscent"					: dict(type=(int, float)),
1942	"openTypeOS2WinDescent"					: dict(type=(int, float)),
1943	"openTypeOS2Type"						: dict(type="integerList", valueValidator=genericIntListValidator, valueOptions=fontInfoOpenTypeOS2TypeOptions),
1944	"openTypeOS2SubscriptXSize"				: dict(type=(int, float)),
1945	"openTypeOS2SubscriptYSize"				: dict(type=(int, float)),
1946	"openTypeOS2SubscriptXOffset"			: dict(type=(int, float)),
1947	"openTypeOS2SubscriptYOffset"			: dict(type=(int, float)),
1948	"openTypeOS2SuperscriptXSize"			: dict(type=(int, float)),
1949	"openTypeOS2SuperscriptYSize"			: dict(type=(int, float)),
1950	"openTypeOS2SuperscriptXOffset"			: dict(type=(int, float)),
1951	"openTypeOS2SuperscriptYOffset"			: dict(type=(int, float)),
1952	"openTypeOS2StrikeoutSize"				: dict(type=(int, float)),
1953	"openTypeOS2StrikeoutPosition"			: dict(type=(int, float)),
1954	"openTypeVheaVertTypoAscender"			: dict(type=(int, float)),
1955	"openTypeVheaVertTypoDescender"			: dict(type=(int, float)),
1956	"openTypeVheaVertTypoLineGap"			: dict(type=(int, float)),
1957	"openTypeVheaCaretSlopeRise"			: dict(type=int),
1958	"openTypeVheaCaretSlopeRun"				: dict(type=int),
1959	"openTypeVheaCaretOffset"				: dict(type=(int, float)),
1960	"postscriptFontName"					: dict(type=str),
1961	"postscriptFullName"					: dict(type=str),
1962	"postscriptSlantAngle"					: dict(type=(float, int)),
1963	"postscriptUniqueID"					: dict(type=int),
1964	"postscriptUnderlineThickness"			: dict(type=(int, float)),
1965	"postscriptUnderlinePosition"			: dict(type=(int, float)),
1966	"postscriptIsFixedPitch"				: dict(type=bool),
1967	"postscriptBlueValues"					: dict(type="integerList", valueValidator=fontInfoPostscriptBluesValidator),
1968	"postscriptOtherBlues"					: dict(type="integerList", valueValidator=fontInfoPostscriptOtherBluesValidator),
1969	"postscriptFamilyBlues"					: dict(type="integerList", valueValidator=fontInfoPostscriptBluesValidator),
1970	"postscriptFamilyOtherBlues"			: dict(type="integerList", valueValidator=fontInfoPostscriptOtherBluesValidator),
1971	"postscriptStemSnapH"					: dict(type="integerList", valueValidator=fontInfoPostscriptStemsValidator),
1972	"postscriptStemSnapV"					: dict(type="integerList", valueValidator=fontInfoPostscriptStemsValidator),
1973	"postscriptBlueFuzz"					: dict(type=(int, float)),
1974	"postscriptBlueShift"					: dict(type=(int, float)),
1975	"postscriptBlueScale"					: dict(type=(float, int)),
1976	"postscriptForceBold"					: dict(type=bool),
1977	"postscriptDefaultWidthX"				: dict(type=(int, float)),
1978	"postscriptNominalWidthX"				: dict(type=(int, float)),
1979	"postscriptWeightName"					: dict(type=str),
1980	"postscriptDefaultCharacter"			: dict(type=str),
1981	"postscriptWindowsCharacterSet"			: dict(type=int, valueValidator=fontInfoPostscriptWindowsCharacterSetValidator),
1982	"macintoshFONDFamilyID"					: dict(type=int),
1983	"macintoshFONDName"						: dict(type=str),
1984}
1985fontInfoAttributesVersion2 = set(fontInfoAttributesVersion2ValueData.keys())
1986
1987fontInfoAttributesVersion3ValueData = deepcopy(fontInfoAttributesVersion2ValueData)
1988fontInfoAttributesVersion3ValueData.update({
1989	"versionMinor"							: dict(type=int, valueValidator=genericNonNegativeIntValidator),
1990	"unitsPerEm"							: dict(type=(int, float), valueValidator=genericNonNegativeNumberValidator),
1991	"openTypeHeadLowestRecPPEM"				: dict(type=int, valueValidator=genericNonNegativeNumberValidator),
1992	"openTypeHheaAscender"					: dict(type=int),
1993	"openTypeHheaDescender"					: dict(type=int),
1994	"openTypeHheaLineGap"					: dict(type=int),
1995	"openTypeHheaCaretOffset"				: dict(type=int),
1996	"openTypeOS2Panose"						: dict(type="integerList", valueValidator=fontInfoVersion3OpenTypeOS2PanoseValidator),
1997	"openTypeOS2TypoAscender"				: dict(type=int),
1998	"openTypeOS2TypoDescender"				: dict(type=int),
1999	"openTypeOS2TypoLineGap"				: dict(type=int),
2000	"openTypeOS2WinAscent"					: dict(type=int, valueValidator=genericNonNegativeNumberValidator),
2001	"openTypeOS2WinDescent"					: dict(type=int, valueValidator=genericNonNegativeNumberValidator),
2002	"openTypeOS2SubscriptXSize"				: dict(type=int),
2003	"openTypeOS2SubscriptYSize"				: dict(type=int),
2004	"openTypeOS2SubscriptXOffset"			: dict(type=int),
2005	"openTypeOS2SubscriptYOffset"			: dict(type=int),
2006	"openTypeOS2SuperscriptXSize"			: dict(type=int),
2007	"openTypeOS2SuperscriptYSize"			: dict(type=int),
2008	"openTypeOS2SuperscriptXOffset"			: dict(type=int),
2009	"openTypeOS2SuperscriptYOffset"			: dict(type=int),
2010	"openTypeOS2StrikeoutSize"				: dict(type=int),
2011	"openTypeOS2StrikeoutPosition"			: dict(type=int),
2012	"openTypeGaspRangeRecords"				: dict(type="dictList", valueValidator=fontInfoOpenTypeGaspRangeRecordsValidator),
2013	"openTypeNameRecords"					: dict(type="dictList", valueValidator=fontInfoOpenTypeNameRecordsValidator),
2014	"openTypeVheaVertTypoAscender"			: dict(type=int),
2015	"openTypeVheaVertTypoDescender"			: dict(type=int),
2016	"openTypeVheaVertTypoLineGap"			: dict(type=int),
2017	"openTypeVheaCaretOffset"				: dict(type=int),
2018	"woffMajorVersion"						: dict(type=int, valueValidator=genericNonNegativeIntValidator),
2019	"woffMinorVersion"						: dict(type=int, valueValidator=genericNonNegativeIntValidator),
2020	"woffMetadataUniqueID"					: dict(type=dict, valueValidator=fontInfoWOFFMetadataUniqueIDValidator),
2021	"woffMetadataVendor"					: dict(type=dict, valueValidator=fontInfoWOFFMetadataVendorValidator),
2022	"woffMetadataCredits"					: dict(type=dict, valueValidator=fontInfoWOFFMetadataCreditsValidator),
2023	"woffMetadataDescription"				: dict(type=dict, valueValidator=fontInfoWOFFMetadataDescriptionValidator),
2024	"woffMetadataLicense"					: dict(type=dict, valueValidator=fontInfoWOFFMetadataLicenseValidator),
2025	"woffMetadataCopyright"					: dict(type=dict, valueValidator=fontInfoWOFFMetadataCopyrightValidator),
2026	"woffMetadataTrademark"					: dict(type=dict, valueValidator=fontInfoWOFFMetadataTrademarkValidator),
2027	"woffMetadataLicensee"					: dict(type=dict, valueValidator=fontInfoWOFFMetadataLicenseeValidator),
2028	"woffMetadataExtensions"				: dict(type=list, valueValidator=fontInfoWOFFMetadataExtensionsValidator),
2029	"guidelines"							: dict(type=list, valueValidator=guidelinesValidator)
2030})
2031fontInfoAttributesVersion3 = set(fontInfoAttributesVersion3ValueData.keys())
2032
2033# insert the type validator for all attrs that
2034# have no defined validator.
2035for attr, dataDict in list(fontInfoAttributesVersion2ValueData.items()):
2036	if "valueValidator" not in dataDict:
2037		dataDict["valueValidator"] = genericTypeValidator
2038
2039for attr, dataDict in list(fontInfoAttributesVersion3ValueData.items()):
2040	if "valueValidator" not in dataDict:
2041		dataDict["valueValidator"] = genericTypeValidator
2042
2043# Version Conversion Support
2044# These are used from converting from version 1
2045# to version 2 or vice-versa.
2046
2047def _flipDict(d):
2048	flipped = {}
2049	for key, value in list(d.items()):
2050		flipped[value] = key
2051	return flipped
2052
2053fontInfoAttributesVersion1To2 = {
2054	"menuName"		: "styleMapFamilyName",
2055	"designer"		: "openTypeNameDesigner",
2056	"designerURL"	: "openTypeNameDesignerURL",
2057	"createdBy"		: "openTypeNameManufacturer",
2058	"vendorURL"		: "openTypeNameManufacturerURL",
2059	"license"		: "openTypeNameLicense",
2060	"licenseURL"	: "openTypeNameLicenseURL",
2061	"ttVersion"		: "openTypeNameVersion",
2062	"ttUniqueID"	: "openTypeNameUniqueID",
2063	"notice"		: "openTypeNameDescription",
2064	"otFamilyName"	: "openTypeNamePreferredFamilyName",
2065	"otStyleName"	: "openTypeNamePreferredSubfamilyName",
2066	"otMacName"		: "openTypeNameCompatibleFullName",
2067	"weightName"	: "postscriptWeightName",
2068	"weightValue"	: "openTypeOS2WeightClass",
2069	"ttVendor"		: "openTypeOS2VendorID",
2070	"uniqueID"		: "postscriptUniqueID",
2071	"fontName"		: "postscriptFontName",
2072	"fondID"		: "macintoshFONDFamilyID",
2073	"fondName"		: "macintoshFONDName",
2074	"defaultWidth"	: "postscriptDefaultWidthX",
2075	"slantAngle"	: "postscriptSlantAngle",
2076	"fullName"		: "postscriptFullName",
2077	# require special value conversion
2078	"fontStyle"		: "styleMapStyleName",
2079	"widthName"		: "openTypeOS2WidthClass",
2080	"msCharSet"		: "postscriptWindowsCharacterSet"
2081}
2082fontInfoAttributesVersion2To1 = _flipDict(fontInfoAttributesVersion1To2)
2083deprecatedFontInfoAttributesVersion2 = set(fontInfoAttributesVersion1To2.keys())
2084
2085_fontStyle1To2 = {
2086	64 : "regular",
2087	1  : "italic",
2088	32 : "bold",
2089	33 : "bold italic"
2090}
2091_fontStyle2To1 = _flipDict(_fontStyle1To2)
2092# Some UFO 1 files have 0
2093_fontStyle1To2[0] = "regular"
2094
2095_widthName1To2 = {
2096	"Ultra-condensed" : 1,
2097	"Extra-condensed" : 2,
2098	"Condensed"		  : 3,
2099	"Semi-condensed"  : 4,
2100	"Medium (normal)" : 5,
2101	"Semi-expanded"	  : 6,
2102	"Expanded"		  : 7,
2103	"Extra-expanded"  : 8,
2104	"Ultra-expanded"  : 9
2105}
2106_widthName2To1 = _flipDict(_widthName1To2)
2107# FontLab's default width value is "Normal".
2108# Many format version 1 UFOs will have this.
2109_widthName1To2["Normal"] = 5
2110# FontLab has an "All" width value. In UFO 1
2111# move this up to "Normal".
2112_widthName1To2["All"] = 5
2113# "medium" appears in a lot of UFO 1 files.
2114_widthName1To2["medium"] = 5
2115# "Medium" appears in a lot of UFO 1 files.
2116_widthName1To2["Medium"] = 5
2117
2118_msCharSet1To2 = {
2119	0	: 1,
2120	1	: 2,
2121	2	: 3,
2122	77	: 4,
2123	128 : 5,
2124	129 : 6,
2125	130 : 7,
2126	134 : 8,
2127	136 : 9,
2128	161 : 10,
2129	162 : 11,
2130	163 : 12,
2131	177 : 13,
2132	178 : 14,
2133	186 : 15,
2134	200 : 16,
2135	204 : 17,
2136	222 : 18,
2137	238 : 19,
2138	255 : 20
2139}
2140_msCharSet2To1 = _flipDict(_msCharSet1To2)
2141
2142# 1 <-> 2
2143
2144def convertFontInfoValueForAttributeFromVersion1ToVersion2(attr, value):
2145	"""
2146	Convert value from version 1 to version 2 format.
2147	Returns the new attribute name and the converted value.
2148	If the value is None, None will be returned for the new value.
2149	"""
2150	# convert floats to ints if possible
2151	if isinstance(value, float):
2152		if int(value) == value:
2153			value = int(value)
2154	if value is not None:
2155		if attr == "fontStyle":
2156			v = _fontStyle1To2.get(value)
2157			if v is None:
2158				raise UFOLibError(f"Cannot convert value ({value!r}) for attribute {attr}.")
2159			value = v
2160		elif attr == "widthName":
2161			v = _widthName1To2.get(value)
2162			if v is None:
2163				raise UFOLibError(f"Cannot convert value ({value!r}) for attribute {attr}.")
2164			value = v
2165		elif attr == "msCharSet":
2166			v = _msCharSet1To2.get(value)
2167			if v is None:
2168				raise UFOLibError(f"Cannot convert value ({value!r}) for attribute {attr}.")
2169			value = v
2170	attr = fontInfoAttributesVersion1To2.get(attr, attr)
2171	return attr, value
2172
2173def convertFontInfoValueForAttributeFromVersion2ToVersion1(attr, value):
2174	"""
2175	Convert value from version 2 to version 1 format.
2176	Returns the new attribute name and the converted value.
2177	If the value is None, None will be returned for the new value.
2178	"""
2179	if value is not None:
2180		if attr == "styleMapStyleName":
2181			value = _fontStyle2To1.get(value)
2182		elif attr == "openTypeOS2WidthClass":
2183			value = _widthName2To1.get(value)
2184		elif attr == "postscriptWindowsCharacterSet":
2185			value = _msCharSet2To1.get(value)
2186	attr = fontInfoAttributesVersion2To1.get(attr, attr)
2187	return attr, value
2188
2189def _convertFontInfoDataVersion1ToVersion2(data):
2190	converted = {}
2191	for attr, value in list(data.items()):
2192		# FontLab gives -1 for the weightValue
2193		# for fonts wil no defined value. Many
2194		# format version 1 UFOs will have this.
2195		if attr == "weightValue" and value == -1:
2196			continue
2197		newAttr, newValue = convertFontInfoValueForAttributeFromVersion1ToVersion2(attr, value)
2198		# skip if the attribute is not part of version 2
2199		if newAttr not in fontInfoAttributesVersion2:
2200			continue
2201		# catch values that can't be converted
2202		if value is None:
2203			raise UFOLibError(f"Cannot convert value ({value!r}) for attribute {newAttr}.")
2204		# store
2205		converted[newAttr] = newValue
2206	return converted
2207
2208def _convertFontInfoDataVersion2ToVersion1(data):
2209	converted = {}
2210	for attr, value in list(data.items()):
2211		newAttr, newValue = convertFontInfoValueForAttributeFromVersion2ToVersion1(attr, value)
2212		# only take attributes that are registered for version 1
2213		if newAttr not in fontInfoAttributesVersion1:
2214			continue
2215		# catch values that can't be converted
2216		if value is None:
2217			raise UFOLibError(f"Cannot convert value ({value!r}) for attribute {newAttr}.")
2218		# store
2219		converted[newAttr] = newValue
2220	return converted
2221
2222# 2 <-> 3
2223
2224_ufo2To3NonNegativeInt = {
2225	"versionMinor",
2226	"openTypeHeadLowestRecPPEM",
2227	"openTypeOS2WinAscent",
2228	"openTypeOS2WinDescent"
2229}
2230_ufo2To3NonNegativeIntOrFloat = {
2231	"unitsPerEm",
2232}
2233_ufo2To3FloatToInt = {
2234	"openTypeHeadLowestRecPPEM",
2235	"openTypeHheaAscender",
2236	"openTypeHheaDescender",
2237	"openTypeHheaLineGap",
2238	"openTypeHheaCaretOffset",
2239	"openTypeOS2TypoAscender",
2240	"openTypeOS2TypoDescender",
2241	"openTypeOS2TypoLineGap",
2242	"openTypeOS2WinAscent",
2243	"openTypeOS2WinDescent",
2244	"openTypeOS2SubscriptXSize",
2245	"openTypeOS2SubscriptYSize",
2246	"openTypeOS2SubscriptXOffset",
2247	"openTypeOS2SubscriptYOffset",
2248	"openTypeOS2SuperscriptXSize",
2249	"openTypeOS2SuperscriptYSize",
2250	"openTypeOS2SuperscriptXOffset",
2251	"openTypeOS2SuperscriptYOffset",
2252	"openTypeOS2StrikeoutSize",
2253	"openTypeOS2StrikeoutPosition",
2254	"openTypeVheaVertTypoAscender",
2255	"openTypeVheaVertTypoDescender",
2256	"openTypeVheaVertTypoLineGap",
2257	"openTypeVheaCaretOffset"
2258}
2259
2260def convertFontInfoValueForAttributeFromVersion2ToVersion3(attr, value):
2261	"""
2262	Convert value from version 2 to version 3 format.
2263	Returns the new attribute name and the converted value.
2264	If the value is None, None will be returned for the new value.
2265	"""
2266	if attr in _ufo2To3FloatToInt:
2267		try:
2268			value = round(value)
2269		except (ValueError, TypeError):
2270			raise UFOLibError("Could not convert value for %s." % attr)
2271	if attr in _ufo2To3NonNegativeInt:
2272		try:
2273			value = int(abs(value))
2274		except (ValueError, TypeError):
2275			raise UFOLibError("Could not convert value for %s." % attr)
2276	elif attr in _ufo2To3NonNegativeIntOrFloat:
2277		try:
2278			v = float(abs(value))
2279		except (ValueError, TypeError):
2280			raise UFOLibError("Could not convert value for %s." % attr)
2281		if v == int(v):
2282			v = int(v)
2283		if v != value:
2284			value = v
2285	return attr, value
2286
2287def convertFontInfoValueForAttributeFromVersion3ToVersion2(attr, value):
2288	"""
2289	Convert value from version 3 to version 2 format.
2290	Returns the new attribute name and the converted value.
2291	If the value is None, None will be returned for the new value.
2292	"""
2293	return attr, value
2294
2295def _convertFontInfoDataVersion3ToVersion2(data):
2296	converted = {}
2297	for attr, value in list(data.items()):
2298		newAttr, newValue = convertFontInfoValueForAttributeFromVersion3ToVersion2(attr, value)
2299		if newAttr not in fontInfoAttributesVersion2:
2300			continue
2301		converted[newAttr] = newValue
2302	return converted
2303
2304def _convertFontInfoDataVersion2ToVersion3(data):
2305	converted = {}
2306	for attr, value in list(data.items()):
2307		attr, value = convertFontInfoValueForAttributeFromVersion2ToVersion3(attr, value)
2308		converted[attr] = value
2309	return converted
2310
2311if __name__ == "__main__":
2312	import doctest
2313	doctest.testmod()
2314