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