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