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