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