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