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