1from __future__ import annotations 2 3import collections 4import copy 5import itertools 6import math 7import os 8import posixpath 9from io import BytesIO, StringIO 10from textwrap import indent 11from typing import Any, Dict, List, MutableMapping, Optional, Tuple, Union, cast 12 13from fontTools.misc import etree as ET 14from fontTools.misc import plistlib 15from fontTools.misc.loggingTools import LogMixin 16from fontTools.misc.textTools import tobytes, tostr 17 18""" 19 designSpaceDocument 20 21 - read and write designspace files 22""" 23 24__all__ = [ 25 'AxisDescriptor', 26 'AxisLabelDescriptor', 27 'BaseDocReader', 28 'BaseDocWriter', 29 'DesignSpaceDocument', 30 'DesignSpaceDocumentError', 31 'DiscreteAxisDescriptor', 32 'InstanceDescriptor', 33 'LocationLabelDescriptor', 34 'RangeAxisSubsetDescriptor', 35 'RuleDescriptor', 36 'SourceDescriptor', 37 'ValueAxisSubsetDescriptor', 38 'VariableFontDescriptor', 39] 40 41# ElementTree allows to find namespace-prefixed elements, but not attributes 42# so we have to do it ourselves for 'xml:lang' 43XML_NS = "{http://www.w3.org/XML/1998/namespace}" 44XML_LANG = XML_NS + "lang" 45 46 47def posix(path): 48 """Normalize paths using forward slash to work also on Windows.""" 49 new_path = posixpath.join(*path.split(os.path.sep)) 50 if path.startswith('/'): 51 # The above transformation loses absolute paths 52 new_path = '/' + new_path 53 elif path.startswith(r'\\'): 54 # The above transformation loses leading slashes of UNC path mounts 55 new_path = '//' + new_path 56 return new_path 57 58 59def posixpath_property(private_name): 60 """Generate a propery that holds a path always using forward slashes.""" 61 def getter(self): 62 # Normal getter 63 return getattr(self, private_name) 64 65 def setter(self, value): 66 # The setter rewrites paths using forward slashes 67 if value is not None: 68 value = posix(value) 69 setattr(self, private_name, value) 70 71 return property(getter, setter) 72 73 74class DesignSpaceDocumentError(Exception): 75 def __init__(self, msg, obj=None): 76 self.msg = msg 77 self.obj = obj 78 79 def __str__(self): 80 return str(self.msg) + ( 81 ": %r" % self.obj if self.obj is not None else "") 82 83 84class AsDictMixin(object): 85 86 def asdict(self): 87 d = {} 88 for attr, value in self.__dict__.items(): 89 if attr.startswith("_"): 90 continue 91 if hasattr(value, "asdict"): 92 value = value.asdict() 93 elif isinstance(value, list): 94 value = [ 95 v.asdict() if hasattr(v, "asdict") else v for v in value 96 ] 97 d[attr] = value 98 return d 99 100 101class SimpleDescriptor(AsDictMixin): 102 """ Containers for a bunch of attributes""" 103 104 # XXX this is ugly. The 'print' is inappropriate here, and instead of 105 # assert, it should simply return True/False 106 def compare(self, other): 107 # test if this object contains the same data as the other 108 for attr in self._attrs: 109 try: 110 assert(getattr(self, attr) == getattr(other, attr)) 111 except AssertionError: 112 print("failed attribute", attr, getattr(self, attr), "!=", getattr(other, attr)) 113 114 def __repr__(self): 115 attrs = [f"{a}={repr(getattr(self, a))}," for a in self._attrs] 116 attrs = indent('\n'.join(attrs), ' ') 117 return f"{self.__class__.__name__}(\n{attrs}\n)" 118 119 120class SourceDescriptor(SimpleDescriptor): 121 """Simple container for data related to the source 122 123 .. code:: python 124 125 doc = DesignSpaceDocument() 126 s1 = SourceDescriptor() 127 s1.path = masterPath1 128 s1.name = "master.ufo1" 129 s1.font = defcon.Font("master.ufo1") 130 s1.location = dict(weight=0) 131 s1.familyName = "MasterFamilyName" 132 s1.styleName = "MasterStyleNameOne" 133 s1.localisedFamilyName = dict(fr="Caractère") 134 s1.mutedGlyphNames.append("A") 135 s1.mutedGlyphNames.append("Z") 136 doc.addSource(s1) 137 138 """ 139 flavor = "source" 140 _attrs = ['filename', 'path', 'name', 'layerName', 141 'location', 'copyLib', 142 'copyGroups', 'copyFeatures', 143 'muteKerning', 'muteInfo', 144 'mutedGlyphNames', 145 'familyName', 'styleName', 'localisedFamilyName'] 146 147 filename = posixpath_property("_filename") 148 path = posixpath_property("_path") 149 150 def __init__( 151 self, 152 *, 153 filename=None, 154 path=None, 155 font=None, 156 name=None, 157 location=None, 158 designLocation=None, 159 layerName=None, 160 familyName=None, 161 styleName=None, 162 localisedFamilyName=None, 163 copyLib=False, 164 copyInfo=False, 165 copyGroups=False, 166 copyFeatures=False, 167 muteKerning=False, 168 muteInfo=False, 169 mutedGlyphNames=None, 170 ): 171 self.filename = filename 172 """string. A relative path to the source file, **as it is in the document**. 173 174 MutatorMath + VarLib. 175 """ 176 self.path = path 177 """The absolute path, calculated from filename.""" 178 179 self.font = font 180 """Any Python object. Optional. Points to a representation of this 181 source font that is loaded in memory, as a Python object (e.g. a 182 ``defcon.Font`` or a ``fontTools.ttFont.TTFont``). 183 184 The default document reader will not fill-in this attribute, and the 185 default writer will not use this attribute. It is up to the user of 186 ``designspaceLib`` to either load the resource identified by 187 ``filename`` and store it in this field, or write the contents of 188 this field to the disk and make ```filename`` point to that. 189 """ 190 191 self.name = name 192 """string. Optional. Unique identifier name for this source. 193 194 MutatorMath + Varlib. 195 """ 196 197 self.designLocation = designLocation if designLocation is not None else location or {} 198 """dict. Axis values for this source, in design space coordinates. 199 200 MutatorMath + Varlib. 201 202 This may be only part of the full design location. 203 See :meth:`getFullDesignLocation()` 204 205 .. versionadded:: 5.0 206 """ 207 208 self.layerName = layerName 209 """string. The name of the layer in the source to look for 210 outline data. Default ``None`` which means ``foreground``. 211 """ 212 self.familyName = familyName 213 """string. Family name of this source. Though this data 214 can be extracted from the font, it can be efficient to have it right 215 here. 216 217 Varlib. 218 """ 219 self.styleName = styleName 220 """string. Style name of this source. Though this data 221 can be extracted from the font, it can be efficient to have it right 222 here. 223 224 Varlib. 225 """ 226 self.localisedFamilyName = localisedFamilyName or {} 227 """dict. A dictionary of localised family name strings, keyed by 228 language code. 229 230 If present, will be used to build localized names for all instances. 231 232 .. versionadded:: 5.0 233 """ 234 235 self.copyLib = copyLib 236 """bool. Indicates if the contents of the font.lib need to 237 be copied to the instances. 238 239 MutatorMath. 240 241 .. deprecated:: 5.0 242 """ 243 self.copyInfo = copyInfo 244 """bool. Indicates if the non-interpolating font.info needs 245 to be copied to the instances. 246 247 MutatorMath. 248 249 .. deprecated:: 5.0 250 """ 251 self.copyGroups = copyGroups 252 """bool. Indicates if the groups need to be copied to the 253 instances. 254 255 MutatorMath. 256 257 .. deprecated:: 5.0 258 """ 259 self.copyFeatures = copyFeatures 260 """bool. Indicates if the feature text needs to be 261 copied to the instances. 262 263 MutatorMath. 264 265 .. deprecated:: 5.0 266 """ 267 self.muteKerning = muteKerning 268 """bool. Indicates if the kerning data from this source 269 needs to be muted (i.e. not be part of the calculations). 270 271 MutatorMath only. 272 """ 273 self.muteInfo = muteInfo 274 """bool. Indicated if the interpolating font.info data for 275 this source needs to be muted. 276 277 MutatorMath only. 278 """ 279 self.mutedGlyphNames = mutedGlyphNames or [] 280 """list. Glyphnames that need to be muted in the 281 instances. 282 283 MutatorMath only. 284 """ 285 286 @property 287 def location(self): 288 """dict. Axis values for this source, in design space coordinates. 289 290 MutatorMath + Varlib. 291 292 .. deprecated:: 5.0 293 Use the more explicit alias for this property :attr:`designLocation`. 294 """ 295 return self.designLocation 296 297 @location.setter 298 def location(self, location: Optional[AnisotropicLocationDict]): 299 self.designLocation = location or {} 300 301 def setFamilyName(self, familyName, languageCode="en"): 302 """Setter for :attr:`localisedFamilyName` 303 304 .. versionadded:: 5.0 305 """ 306 self.localisedFamilyName[languageCode] = tostr(familyName) 307 308 def getFamilyName(self, languageCode="en"): 309 """Getter for :attr:`localisedFamilyName` 310 311 .. versionadded:: 5.0 312 """ 313 return self.localisedFamilyName.get(languageCode) 314 315 316 def getFullDesignLocation(self, doc: 'DesignSpaceDocument') -> AnisotropicLocationDict: 317 """Get the complete design location of this source, from its 318 :attr:`designLocation` and the document's axis defaults. 319 320 .. versionadded:: 5.0 321 """ 322 result: AnisotropicLocationDict = {} 323 for axis in doc.axes: 324 if axis.name in self.designLocation: 325 result[axis.name] = self.designLocation[axis.name] 326 else: 327 result[axis.name] = axis.map_forward(axis.default) 328 return result 329 330 331class RuleDescriptor(SimpleDescriptor): 332 """Represents the rule descriptor element: a set of glyph substitutions to 333 trigger conditionally in some parts of the designspace. 334 335 .. code:: python 336 337 r1 = RuleDescriptor() 338 r1.name = "unique.rule.name" 339 r1.conditionSets.append([dict(name="weight", minimum=-10, maximum=10), dict(...)]) 340 r1.conditionSets.append([dict(...), dict(...)]) 341 r1.subs.append(("a", "a.alt")) 342 343 .. code:: xml 344 345 <!-- optional: list of substitution rules --> 346 <rules> 347 <rule name="vertical.bars"> 348 <conditionset> 349 <condition minimum="250.000000" maximum="750.000000" name="weight"/> 350 <condition minimum="100" name="width"/> 351 <condition minimum="10" maximum="40" name="optical"/> 352 </conditionset> 353 <sub name="cent" with="cent.alt"/> 354 <sub name="dollar" with="dollar.alt"/> 355 </rule> 356 </rules> 357 """ 358 _attrs = ['name', 'conditionSets', 'subs'] # what do we need here 359 360 def __init__(self, *, name=None, conditionSets=None, subs=None): 361 self.name = name 362 """string. Unique name for this rule. Can be used to reference this rule data.""" 363 # list of lists of dict(name='aaaa', minimum=0, maximum=1000) 364 self.conditionSets = conditionSets or [] 365 """a list of conditionsets. 366 367 - Each conditionset is a list of conditions. 368 - Each condition is a dict with ``name``, ``minimum`` and ``maximum`` keys. 369 """ 370 # list of substitutions stored as tuples of glyphnames ("a", "a.alt") 371 self.subs = subs or [] 372 """list of substitutions. 373 374 - Each substitution is stored as tuples of glyphnames, e.g. ("a", "a.alt"). 375 - Note: By default, rules are applied first, before other text 376 shaping/OpenType layout, as they are part of the 377 `Required Variation Alternates OpenType feature <https://docs.microsoft.com/en-us/typography/opentype/spec/features_pt#-tag-rvrn>`_. 378 See ref:`rules-element` § Attributes. 379 """ 380 381 382def evaluateRule(rule, location): 383 """Return True if any of the rule's conditionsets matches the given location.""" 384 return any(evaluateConditions(c, location) for c in rule.conditionSets) 385 386 387def evaluateConditions(conditions, location): 388 """Return True if all the conditions matches the given location. 389 390 - If a condition has no minimum, check for < maximum. 391 - If a condition has no maximum, check for > minimum. 392 """ 393 for cd in conditions: 394 value = location[cd['name']] 395 if cd.get('minimum') is None: 396 if value > cd['maximum']: 397 return False 398 elif cd.get('maximum') is None: 399 if cd['minimum'] > value: 400 return False 401 elif not cd['minimum'] <= value <= cd['maximum']: 402 return False 403 return True 404 405 406def processRules(rules, location, glyphNames): 407 """Apply these rules at this location to these glyphnames. 408 409 Return a new list of glyphNames with substitutions applied. 410 411 - rule order matters 412 """ 413 newNames = [] 414 for rule in rules: 415 if evaluateRule(rule, location): 416 for name in glyphNames: 417 swap = False 418 for a, b in rule.subs: 419 if name == a: 420 swap = True 421 break 422 if swap: 423 newNames.append(b) 424 else: 425 newNames.append(name) 426 glyphNames = newNames 427 newNames = [] 428 return glyphNames 429 430 431AnisotropicLocationDict = Dict[str, Union[float, Tuple[float, float]]] 432SimpleLocationDict = Dict[str, float] 433 434 435class InstanceDescriptor(SimpleDescriptor): 436 """Simple container for data related to the instance 437 438 439 .. code:: python 440 441 i2 = InstanceDescriptor() 442 i2.path = instancePath2 443 i2.familyName = "InstanceFamilyName" 444 i2.styleName = "InstanceStyleName" 445 i2.name = "instance.ufo2" 446 # anisotropic location 447 i2.designLocation = dict(weight=500, width=(400,300)) 448 i2.postScriptFontName = "InstancePostscriptName" 449 i2.styleMapFamilyName = "InstanceStyleMapFamilyName" 450 i2.styleMapStyleName = "InstanceStyleMapStyleName" 451 i2.lib['com.coolDesignspaceApp.specimenText'] = 'Hamburgerwhatever' 452 doc.addInstance(i2) 453 """ 454 flavor = "instance" 455 _defaultLanguageCode = "en" 456 _attrs = ['filename', 457 'path', 458 'name', 459 'locationLabel', 460 'designLocation', 461 'userLocation', 462 'familyName', 463 'styleName', 464 'postScriptFontName', 465 'styleMapFamilyName', 466 'styleMapStyleName', 467 'localisedFamilyName', 468 'localisedStyleName', 469 'localisedStyleMapFamilyName', 470 'localisedStyleMapStyleName', 471 'glyphs', 472 'kerning', 473 'info', 474 'lib'] 475 476 filename = posixpath_property("_filename") 477 path = posixpath_property("_path") 478 479 def __init__( 480 self, 481 *, 482 filename=None, 483 path=None, 484 font=None, 485 name=None, 486 location=None, 487 locationLabel=None, 488 designLocation=None, 489 userLocation=None, 490 familyName=None, 491 styleName=None, 492 postScriptFontName=None, 493 styleMapFamilyName=None, 494 styleMapStyleName=None, 495 localisedFamilyName=None, 496 localisedStyleName=None, 497 localisedStyleMapFamilyName=None, 498 localisedStyleMapStyleName=None, 499 glyphs=None, 500 kerning=True, 501 info=True, 502 lib=None, 503 ): 504 self.filename = filename 505 """string. Relative path to the instance file, **as it is 506 in the document**. The file may or may not exist. 507 508 MutatorMath + VarLib. 509 """ 510 self.path = path 511 """string. Absolute path to the instance file, calculated from 512 the document path and the string in the filename attr. The file may 513 or may not exist. 514 515 MutatorMath. 516 """ 517 self.font = font 518 """Same as :attr:`SourceDescriptor.font` 519 520 .. seealso:: :attr:`SourceDescriptor.font` 521 """ 522 self.name = name 523 """string. Unique identifier name of the instance, used to 524 identify it if it needs to be referenced from elsewhere in the 525 document. 526 """ 527 self.locationLabel = locationLabel 528 """Name of a :class:`LocationLabelDescriptor`. If 529 provided, the instance should have the same location as the 530 LocationLabel. 531 532 .. seealso:: 533 :meth:`getFullDesignLocation` 534 :meth:`getFullUserLocation` 535 536 .. versionadded:: 5.0 537 """ 538 self.designLocation: AnisotropicLocationDict = designLocation if designLocation is not None else (location or {}) 539 """dict. Axis values for this instance, in design space coordinates. 540 541 MutatorMath + Varlib. 542 543 .. seealso:: This may be only part of the full location. See: 544 :meth:`getFullDesignLocation` 545 :meth:`getFullUserLocation` 546 547 .. versionadded:: 5.0 548 """ 549 self.userLocation: SimpleLocationDict = userLocation or {} 550 """dict. Axis values for this instance, in user space coordinates. 551 552 MutatorMath + Varlib. 553 554 .. seealso:: This may be only part of the full location. See: 555 :meth:`getFullDesignLocation` 556 :meth:`getFullUserLocation` 557 558 .. versionadded:: 5.0 559 """ 560 self.familyName = familyName 561 """string. Family name of this instance. 562 563 MutatorMath + Varlib. 564 """ 565 self.styleName = styleName 566 """string. Style name of this instance. 567 568 MutatorMath + Varlib. 569 """ 570 self.postScriptFontName = postScriptFontName 571 """string. Postscript fontname for this instance. 572 573 MutatorMath + Varlib. 574 """ 575 self.styleMapFamilyName = styleMapFamilyName 576 """string. StyleMap familyname for this instance. 577 578 MutatorMath + Varlib. 579 """ 580 self.styleMapStyleName = styleMapStyleName 581 """string. StyleMap stylename for this instance. 582 583 MutatorMath + Varlib. 584 """ 585 self.localisedFamilyName = localisedFamilyName or {} 586 """dict. A dictionary of localised family name 587 strings, keyed by language code. 588 """ 589 self.localisedStyleName = localisedStyleName or {} 590 """dict. A dictionary of localised stylename 591 strings, keyed by language code. 592 """ 593 self.localisedStyleMapFamilyName = localisedStyleMapFamilyName or {} 594 """A dictionary of localised style map 595 familyname strings, keyed by language code. 596 """ 597 self.localisedStyleMapStyleName = localisedStyleMapStyleName or {} 598 """A dictionary of localised style map 599 stylename strings, keyed by language code. 600 """ 601 self.glyphs = glyphs or {} 602 """dict for special master definitions for glyphs. If glyphs 603 need special masters (to record the results of executed rules for 604 example). 605 606 MutatorMath. 607 608 .. deprecated:: 5.0 609 Use rules or sparse sources instead. 610 """ 611 self.kerning = kerning 612 """ bool. Indicates if this instance needs its kerning 613 calculated. 614 615 MutatorMath. 616 617 .. deprecated:: 5.0 618 """ 619 self.info = info 620 """bool. Indicated if this instance needs the interpolating 621 font.info calculated. 622 623 .. deprecated:: 5.0 624 """ 625 626 self.lib = lib or {} 627 """Custom data associated with this instance.""" 628 629 @property 630 def location(self): 631 """dict. Axis values for this instance. 632 633 MutatorMath + Varlib. 634 635 .. deprecated:: 5.0 636 Use the more explicit alias for this property :attr:`designLocation`. 637 """ 638 return self.designLocation 639 640 @location.setter 641 def location(self, location: Optional[AnisotropicLocationDict]): 642 self.designLocation = location or {} 643 644 def setStyleName(self, styleName, languageCode="en"): 645 """These methods give easier access to the localised names.""" 646 self.localisedStyleName[languageCode] = tostr(styleName) 647 648 def getStyleName(self, languageCode="en"): 649 return self.localisedStyleName.get(languageCode) 650 651 def setFamilyName(self, familyName, languageCode="en"): 652 self.localisedFamilyName[languageCode] = tostr(familyName) 653 654 def getFamilyName(self, languageCode="en"): 655 return self.localisedFamilyName.get(languageCode) 656 657 def setStyleMapStyleName(self, styleMapStyleName, languageCode="en"): 658 self.localisedStyleMapStyleName[languageCode] = tostr(styleMapStyleName) 659 660 def getStyleMapStyleName(self, languageCode="en"): 661 return self.localisedStyleMapStyleName.get(languageCode) 662 663 def setStyleMapFamilyName(self, styleMapFamilyName, languageCode="en"): 664 self.localisedStyleMapFamilyName[languageCode] = tostr(styleMapFamilyName) 665 666 def getStyleMapFamilyName(self, languageCode="en"): 667 return self.localisedStyleMapFamilyName.get(languageCode) 668 669 def clearLocation(self, axisName: Optional[str] = None): 670 """Clear all location-related fields. Ensures that 671 :attr:``designLocation`` and :attr:``userLocation`` are dictionaries 672 (possibly empty if clearing everything). 673 674 In order to update the location of this instance wholesale, a user 675 should first clear all the fields, then change the field(s) for which 676 they have data. 677 678 .. code:: python 679 680 instance.clearLocation() 681 instance.designLocation = {'Weight': (34, 36.5), 'Width': 100} 682 instance.userLocation = {'Opsz': 16} 683 684 In order to update a single axis location, the user should only clear 685 that axis, then edit the values: 686 687 .. code:: python 688 689 instance.clearLocation('Weight') 690 instance.designLocation['Weight'] = (34, 36.5) 691 692 Args: 693 axisName: if provided, only clear the location for that axis. 694 695 .. versionadded:: 5.0 696 """ 697 self.locationLabel = None 698 if axisName is None: 699 self.designLocation = {} 700 self.userLocation = {} 701 else: 702 if self.designLocation is None: 703 self.designLocation = {} 704 if axisName in self.designLocation: 705 del self.designLocation[axisName] 706 if self.userLocation is None: 707 self.userLocation = {} 708 if axisName in self.userLocation: 709 del self.userLocation[axisName] 710 711 def getLocationLabelDescriptor(self, doc: 'DesignSpaceDocument') -> Optional[LocationLabelDescriptor]: 712 """Get the :class:`LocationLabelDescriptor` instance that matches 713 this instances's :attr:`locationLabel`. 714 715 Raises if the named label can't be found. 716 717 .. versionadded:: 5.0 718 """ 719 if self.locationLabel is None: 720 return None 721 label = doc.getLocationLabel(self.locationLabel) 722 if label is None: 723 raise DesignSpaceDocumentError( 724 'InstanceDescriptor.getLocationLabelDescriptor(): ' 725 f'unknown location label `{self.locationLabel}` in instance `{self.name}`.' 726 ) 727 return label 728 729 def getFullDesignLocation(self, doc: 'DesignSpaceDocument') -> AnisotropicLocationDict: 730 """Get the complete design location of this instance, by combining data 731 from the various location fields, default axis values and mappings, and 732 top-level location labels. 733 734 The source of truth for this instance's location is determined for each 735 axis independently by taking the first not-None field in this list: 736 737 - ``locationLabel``: the location along this axis is the same as the 738 matching STAT format 4 label. No anisotropy. 739 - ``designLocation[axisName]``: the explicit design location along this 740 axis, possibly anisotropic. 741 - ``userLocation[axisName]``: the explicit user location along this 742 axis. No anisotropy. 743 - ``axis.default``: default axis value. No anisotropy. 744 745 .. versionadded:: 5.0 746 """ 747 label = self.getLocationLabelDescriptor(doc) 748 if label is not None: 749 return doc.map_forward(label.userLocation) # type: ignore 750 result: AnisotropicLocationDict = {} 751 for axis in doc.axes: 752 if axis.name in self.designLocation: 753 result[axis.name] = self.designLocation[axis.name] 754 elif axis.name in self.userLocation: 755 result[axis.name] = axis.map_forward(self.userLocation[axis.name]) 756 else: 757 result[axis.name] = axis.map_forward(axis.default) 758 return result 759 760 def getFullUserLocation(self, doc: 'DesignSpaceDocument') -> SimpleLocationDict: 761 """Get the complete user location for this instance. 762 763 .. seealso:: :meth:`getFullDesignLocation` 764 765 .. versionadded:: 5.0 766 """ 767 return doc.map_backward(self.getFullDesignLocation(doc)) 768 769 770def tagForAxisName(name): 771 # try to find or make a tag name for this axis name 772 names = { 773 'weight': ('wght', dict(en = 'Weight')), 774 'width': ('wdth', dict(en = 'Width')), 775 'optical': ('opsz', dict(en = 'Optical Size')), 776 'slant': ('slnt', dict(en = 'Slant')), 777 'italic': ('ital', dict(en = 'Italic')), 778 } 779 if name.lower() in names: 780 return names[name.lower()] 781 if len(name) < 4: 782 tag = name + "*" * (4 - len(name)) 783 else: 784 tag = name[:4] 785 return tag, dict(en=name) 786 787 788class AbstractAxisDescriptor(SimpleDescriptor): 789 flavor = "axis" 790 791 def __init__( 792 self, 793 *, 794 tag=None, 795 name=None, 796 labelNames=None, 797 hidden=False, 798 map=None, 799 axisOrdering=None, 800 axisLabels=None, 801 ): 802 # opentype tag for this axis 803 self.tag = tag 804 """string. Four letter tag for this axis. Some might be 805 registered at the `OpenType 806 specification <https://www.microsoft.com/typography/otspec/fvar.htm#VAT>`__. 807 Privately-defined axis tags must begin with an uppercase letter and 808 use only uppercase letters or digits. 809 """ 810 # name of the axis used in locations 811 self.name = name 812 """string. Name of the axis as it is used in the location dicts. 813 814 MutatorMath + Varlib. 815 """ 816 # names for UI purposes, if this is not a standard axis, 817 self.labelNames = labelNames or {} 818 """dict. When defining a non-registered axis, it will be 819 necessary to define user-facing readable names for the axis. Keyed by 820 xml:lang code. Values are required to be ``unicode`` strings, even if 821 they only contain ASCII characters. 822 """ 823 self.hidden = hidden 824 """bool. Whether this axis should be hidden in user interfaces. 825 """ 826 self.map = map or [] 827 """list of input / output values that can describe a warp of user space 828 to design space coordinates. If no map values are present, it is assumed 829 user space is the same as design space, as in [(minimum, minimum), 830 (maximum, maximum)]. 831 832 Varlib. 833 """ 834 self.axisOrdering = axisOrdering 835 """STAT table field ``axisOrdering``. 836 837 See: `OTSpec STAT Axis Record <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-records>`_ 838 839 .. versionadded:: 5.0 840 """ 841 self.axisLabels: List[AxisLabelDescriptor] = axisLabels or [] 842 """STAT table entries for Axis Value Tables format 1, 2, 3. 843 844 See: `OTSpec STAT Axis Value Tables <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-tables>`_ 845 846 .. versionadded:: 5.0 847 """ 848 849 850class AxisDescriptor(AbstractAxisDescriptor): 851 """ Simple container for the axis data. 852 853 Add more localisations? 854 855 .. code:: python 856 857 a1 = AxisDescriptor() 858 a1.minimum = 1 859 a1.maximum = 1000 860 a1.default = 400 861 a1.name = "weight" 862 a1.tag = "wght" 863 a1.labelNames['fa-IR'] = "قطر" 864 a1.labelNames['en'] = "Wéíght" 865 a1.map = [(1.0, 10.0), (400.0, 66.0), (1000.0, 990.0)] 866 a1.axisOrdering = 1 867 a1.axisLabels = [ 868 AxisLabelDescriptor(name="Regular", userValue=400, elidable=True) 869 ] 870 doc.addAxis(a1) 871 """ 872 _attrs = ['tag', 'name', 'maximum', 'minimum', 'default', 'map', 'axisOrdering', 'axisLabels'] 873 874 def __init__( 875 self, 876 *, 877 tag=None, 878 name=None, 879 labelNames=None, 880 minimum=None, 881 default=None, 882 maximum=None, 883 hidden=False, 884 map=None, 885 axisOrdering=None, 886 axisLabels=None, 887 ): 888 super().__init__( 889 tag=tag, 890 name=name, 891 labelNames=labelNames, 892 hidden=hidden, 893 map=map, 894 axisOrdering=axisOrdering, 895 axisLabels=axisLabels, 896 ) 897 self.minimum = minimum 898 """number. The minimum value for this axis in user space. 899 900 MutatorMath + Varlib. 901 """ 902 self.maximum = maximum 903 """number. The maximum value for this axis in user space. 904 905 MutatorMath + Varlib. 906 """ 907 self.default = default 908 """number. The default value for this axis, i.e. when a new location is 909 created, this is the value this axis will get in user space. 910 911 MutatorMath + Varlib. 912 """ 913 914 def serialize(self): 915 # output to a dict, used in testing 916 return dict( 917 tag=self.tag, 918 name=self.name, 919 labelNames=self.labelNames, 920 maximum=self.maximum, 921 minimum=self.minimum, 922 default=self.default, 923 hidden=self.hidden, 924 map=self.map, 925 axisOrdering=self.axisOrdering, 926 axisLabels=self.axisLabels, 927 ) 928 929 def map_forward(self, v): 930 """Maps value from axis mapping's input (user) to output (design).""" 931 from fontTools.varLib.models import piecewiseLinearMap 932 933 if not self.map: 934 return v 935 return piecewiseLinearMap(v, {k: v for k, v in self.map}) 936 937 def map_backward(self, v): 938 """Maps value from axis mapping's output (design) to input (user).""" 939 from fontTools.varLib.models import piecewiseLinearMap 940 941 if isinstance(v, tuple): 942 v = v[0] 943 if not self.map: 944 return v 945 return piecewiseLinearMap(v, {v: k for k, v in self.map}) 946 947 948class DiscreteAxisDescriptor(AbstractAxisDescriptor): 949 """Container for discrete axis data. 950 951 Use this for axes that do not interpolate. The main difference from a 952 continuous axis is that a continuous axis has a ``minimum`` and ``maximum``, 953 while a discrete axis has a list of ``values``. 954 955 Example: an Italic axis with 2 stops, Roman and Italic, that are not 956 compatible. The axis still allows to bind together the full font family, 957 which is useful for the STAT table, however it can't become a variation 958 axis in a VF. 959 960 .. code:: python 961 962 a2 = DiscreteAxisDescriptor() 963 a2.values = [0, 1] 964 a2.default = 0 965 a2.name = "Italic" 966 a2.tag = "ITAL" 967 a2.labelNames['fr'] = "Italique" 968 a2.map = [(0, 0), (1, -11)] 969 a2.axisOrdering = 2 970 a2.axisLabels = [ 971 AxisLabelDescriptor(name="Roman", userValue=0, elidable=True) 972 ] 973 doc.addAxis(a2) 974 975 .. versionadded:: 5.0 976 """ 977 978 flavor = "axis" 979 _attrs = ('tag', 'name', 'values', 'default', 'map', 'axisOrdering', 'axisLabels') 980 981 def __init__( 982 self, 983 *, 984 tag=None, 985 name=None, 986 labelNames=None, 987 values=None, 988 default=None, 989 hidden=False, 990 map=None, 991 axisOrdering=None, 992 axisLabels=None, 993 ): 994 super().__init__( 995 tag=tag, 996 name=name, 997 labelNames=labelNames, 998 hidden=hidden, 999 map=map, 1000 axisOrdering=axisOrdering, 1001 axisLabels=axisLabels, 1002 ) 1003 self.default: float = default 1004 """The default value for this axis, i.e. when a new location is 1005 created, this is the value this axis will get in user space. 1006 1007 However, this default value is less important than in continuous axes: 1008 1009 - it doesn't define the "neutral" version of outlines from which 1010 deltas would apply, as this axis does not interpolate. 1011 - it doesn't provide the reference glyph set for the designspace, as 1012 fonts at each value can have different glyph sets. 1013 """ 1014 self.values: List[float] = values or [] 1015 """List of possible values for this axis. Contrary to continuous axes, 1016 only the values in this list can be taken by the axis, nothing in-between. 1017 """ 1018 1019 def map_forward(self, value): 1020 """Maps value from axis mapping's input to output. 1021 1022 Returns value unchanged if no mapping entry is found. 1023 1024 Note: for discrete axes, each value must have its mapping entry, if 1025 you intend that value to be mapped. 1026 """ 1027 return next((v for k, v in self.map if k == value), value) 1028 1029 def map_backward(self, value): 1030 """Maps value from axis mapping's output to input. 1031 1032 Returns value unchanged if no mapping entry is found. 1033 1034 Note: for discrete axes, each value must have its mapping entry, if 1035 you intend that value to be mapped. 1036 """ 1037 if isinstance(value, tuple): 1038 value = value[0] 1039 return next((k for k, v in self.map if v == value), value) 1040 1041 1042class AxisLabelDescriptor(SimpleDescriptor): 1043 """Container for axis label data. 1044 1045 Analogue of OpenType's STAT data for a single axis (formats 1, 2 and 3). 1046 All values are user values. 1047 See: `OTSpec STAT Axis value table, format 1, 2, 3 <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-table-format-1>`_ 1048 1049 The STAT format of the Axis value depends on which field are filled-in, 1050 see :meth:`getFormat` 1051 1052 .. versionadded:: 5.0 1053 """ 1054 1055 flavor = "label" 1056 _attrs = ('userMinimum', 'userValue', 'userMaximum', 'name', 'elidable', 'olderSibling', 'linkedUserValue', 'labelNames') 1057 1058 def __init__( 1059 self, 1060 *, 1061 name, 1062 userValue, 1063 userMinimum=None, 1064 userMaximum=None, 1065 elidable=False, 1066 olderSibling=False, 1067 linkedUserValue=None, 1068 labelNames=None, 1069 ): 1070 self.userMinimum: Optional[float] = userMinimum 1071 """STAT field ``rangeMinValue`` (format 2).""" 1072 self.userValue: float = userValue 1073 """STAT field ``value`` (format 1, 3) or ``nominalValue`` (format 2).""" 1074 self.userMaximum: Optional[float] = userMaximum 1075 """STAT field ``rangeMaxValue`` (format 2).""" 1076 self.name: str = name 1077 """Label for this axis location, STAT field ``valueNameID``.""" 1078 self.elidable: bool = elidable 1079 """STAT flag ``ELIDABLE_AXIS_VALUE_NAME``. 1080 1081 See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_ 1082 """ 1083 self.olderSibling: bool = olderSibling 1084 """STAT flag ``OLDER_SIBLING_FONT_ATTRIBUTE``. 1085 1086 See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_ 1087 """ 1088 self.linkedUserValue: Optional[float] = linkedUserValue 1089 """STAT field ``linkedValue`` (format 3).""" 1090 self.labelNames: MutableMapping[str, str] = labelNames or {} 1091 """User-facing translations of this location's label. Keyed by 1092 ``xml:lang`` code. 1093 """ 1094 1095 def getFormat(self) -> int: 1096 """Determine which format of STAT Axis value to use to encode this label. 1097 1098 =========== ========= =========== =========== =============== 1099 STAT Format userValue userMinimum userMaximum linkedUserValue 1100 =========== ========= =========== =========== =============== 1101 1 ✅ ❌ ❌ ❌ 1102 2 ✅ ✅ ✅ ❌ 1103 3 ✅ ❌ ❌ ✅ 1104 =========== ========= =========== =========== =============== 1105 """ 1106 if self.linkedUserValue is not None: 1107 return 3 1108 if self.userMinimum is not None or self.userMaximum is not None: 1109 return 2 1110 return 1 1111 1112 @property 1113 def defaultName(self) -> str: 1114 """Return the English name from :attr:`labelNames` or the :attr:`name`.""" 1115 return self.labelNames.get("en") or self.name 1116 1117 1118class LocationLabelDescriptor(SimpleDescriptor): 1119 """Container for location label data. 1120 1121 Analogue of OpenType's STAT data for a free-floating location (format 4). 1122 All values are user values. 1123 1124 See: `OTSpec STAT Axis value table, format 4 <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-table-format-4>`_ 1125 1126 .. versionadded:: 5.0 1127 """ 1128 1129 flavor = "label" 1130 _attrs = ('name', 'elidable', 'olderSibling', 'userLocation', 'labelNames') 1131 1132 def __init__( 1133 self, 1134 *, 1135 name, 1136 userLocation, 1137 elidable=False, 1138 olderSibling=False, 1139 labelNames=None, 1140 ): 1141 self.name: str = name 1142 """Label for this named location, STAT field ``valueNameID``.""" 1143 self.userLocation: SimpleLocationDict = userLocation or {} 1144 """Location in user coordinates along each axis. 1145 1146 If an axis is not mentioned, it is assumed to be at its default location. 1147 1148 .. seealso:: This may be only part of the full location. See: 1149 :meth:`getFullUserLocation` 1150 """ 1151 self.elidable: bool = elidable 1152 """STAT flag ``ELIDABLE_AXIS_VALUE_NAME``. 1153 1154 See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_ 1155 """ 1156 self.olderSibling: bool = olderSibling 1157 """STAT flag ``OLDER_SIBLING_FONT_ATTRIBUTE``. 1158 1159 See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_ 1160 """ 1161 self.labelNames: Dict[str, str] = labelNames or {} 1162 """User-facing translations of this location's label. Keyed by 1163 xml:lang code. 1164 """ 1165 1166 @property 1167 def defaultName(self) -> str: 1168 """Return the English name from :attr:`labelNames` or the :attr:`name`.""" 1169 return self.labelNames.get("en") or self.name 1170 1171 def getFullUserLocation(self, doc: 'DesignSpaceDocument') -> SimpleLocationDict: 1172 """Get the complete user location of this label, by combining data 1173 from the explicit user location and default axis values. 1174 1175 .. versionadded:: 5.0 1176 """ 1177 return { 1178 axis.name: self.userLocation.get(axis.name, axis.default) 1179 for axis in doc.axes 1180 } 1181 1182 1183class VariableFontDescriptor(SimpleDescriptor): 1184 """Container for variable fonts, sub-spaces of the Designspace. 1185 1186 Use-cases: 1187 1188 - From a single DesignSpace with discrete axes, define 1 variable font 1189 per value on the discrete axes. Before version 5, you would have needed 1190 1 DesignSpace per such variable font, and a lot of data duplication. 1191 - From a big variable font with many axes, define subsets of that variable 1192 font that only include some axes and freeze other axes at a given location. 1193 1194 .. versionadded:: 5.0 1195 """ 1196 1197 flavor = "variable-font" 1198 _attrs = ('filename', 'axisSubsets', 'lib') 1199 1200 filename = posixpath_property("_filename") 1201 1202 def __init__(self, *, name, filename=None, axisSubsets=None, lib=None): 1203 self.name: str = name 1204 """string, required. Name of this variable to identify it during the 1205 build process and from other parts of the document, and also as a 1206 filename in case the filename property is empty. 1207 1208 VarLib. 1209 """ 1210 self.filename: str = filename 1211 """string, optional. Relative path to the variable font file, **as it is 1212 in the document**. The file may or may not exist. 1213 1214 If not specified, the :attr:`name` will be used as a basename for the file. 1215 """ 1216 self.axisSubsets: List[Union[RangeAxisSubsetDescriptor, ValueAxisSubsetDescriptor]] = axisSubsets or [] 1217 """Axis subsets to include in this variable font. 1218 1219 If an axis is not mentioned, assume that we only want the default 1220 location of that axis (same as a :class:`ValueAxisSubsetDescriptor`). 1221 """ 1222 self.lib: MutableMapping[str, Any] = lib or {} 1223 """Custom data associated with this variable font.""" 1224 1225 1226class RangeAxisSubsetDescriptor(SimpleDescriptor): 1227 """Subset of a continuous axis to include in a variable font. 1228 1229 .. versionadded:: 5.0 1230 """ 1231 flavor = "axis-subset" 1232 _attrs = ('name', 'userMinimum', 'userDefault', 'userMaximum') 1233 1234 def __init__(self, *, name, userMinimum=-math.inf, userDefault=None, userMaximum=math.inf): 1235 self.name: str = name 1236 """Name of the :class:`AxisDescriptor` to subset.""" 1237 self.userMinimum: float = userMinimum 1238 """New minimum value of the axis in the target variable font. 1239 If not specified, assume the same minimum value as the full axis. 1240 (default = ``-math.inf``) 1241 """ 1242 self.userDefault: Optional[float] = userDefault 1243 """New default value of the axis in the target variable font. 1244 If not specified, assume the same default value as the full axis. 1245 (default = ``None``) 1246 """ 1247 self.userMaximum: float = userMaximum 1248 """New maximum value of the axis in the target variable font. 1249 If not specified, assume the same maximum value as the full axis. 1250 (default = ``math.inf``) 1251 """ 1252 1253 1254class ValueAxisSubsetDescriptor(SimpleDescriptor): 1255 """Single value of a discrete or continuous axis to use in a variable font. 1256 1257 .. versionadded:: 5.0 1258 """ 1259 flavor = "axis-subset" 1260 _attrs = ('name', 'userValue') 1261 1262 def __init__(self, *, name, userValue): 1263 self.name: str = name 1264 """Name of the :class:`AxisDescriptor` or :class:`DiscreteAxisDescriptor` 1265 to "snapshot" or "freeze". 1266 """ 1267 self.userValue: float = userValue 1268 """Value in user coordinates at which to freeze the given axis.""" 1269 1270 1271class BaseDocWriter(object): 1272 _whiteSpace = " " 1273 axisDescriptorClass = AxisDescriptor 1274 discreteAxisDescriptorClass = DiscreteAxisDescriptor 1275 axisLabelDescriptorClass = AxisLabelDescriptor 1276 locationLabelDescriptorClass = LocationLabelDescriptor 1277 ruleDescriptorClass = RuleDescriptor 1278 sourceDescriptorClass = SourceDescriptor 1279 variableFontDescriptorClass = VariableFontDescriptor 1280 valueAxisSubsetDescriptorClass = ValueAxisSubsetDescriptor 1281 rangeAxisSubsetDescriptorClass = RangeAxisSubsetDescriptor 1282 instanceDescriptorClass = InstanceDescriptor 1283 1284 @classmethod 1285 def getAxisDecriptor(cls): 1286 return cls.axisDescriptorClass() 1287 1288 @classmethod 1289 def getSourceDescriptor(cls): 1290 return cls.sourceDescriptorClass() 1291 1292 @classmethod 1293 def getInstanceDescriptor(cls): 1294 return cls.instanceDescriptorClass() 1295 1296 @classmethod 1297 def getRuleDescriptor(cls): 1298 return cls.ruleDescriptorClass() 1299 1300 def __init__(self, documentPath, documentObject: DesignSpaceDocument): 1301 self.path = documentPath 1302 self.documentObject = documentObject 1303 self.effectiveFormatTuple = self._getEffectiveFormatTuple() 1304 self.root = ET.Element("designspace") 1305 1306 def write(self, pretty=True, encoding="UTF-8", xml_declaration=True): 1307 self.root.attrib['format'] = ".".join(str(i) for i in self.effectiveFormatTuple) 1308 1309 if self.documentObject.axes or self.documentObject.elidedFallbackName is not None: 1310 axesElement = ET.Element("axes") 1311 if self.documentObject.elidedFallbackName is not None: 1312 axesElement.attrib['elidedfallbackname'] = self.documentObject.elidedFallbackName 1313 self.root.append(axesElement) 1314 for axisObject in self.documentObject.axes: 1315 self._addAxis(axisObject) 1316 1317 if self.documentObject.locationLabels: 1318 labelsElement = ET.Element("labels") 1319 for labelObject in self.documentObject.locationLabels: 1320 self._addLocationLabel(labelsElement, labelObject) 1321 self.root.append(labelsElement) 1322 1323 if self.documentObject.rules: 1324 if getattr(self.documentObject, "rulesProcessingLast", False): 1325 attributes = {"processing": "last"} 1326 else: 1327 attributes = {} 1328 self.root.append(ET.Element("rules", attributes)) 1329 for ruleObject in self.documentObject.rules: 1330 self._addRule(ruleObject) 1331 1332 if self.documentObject.sources: 1333 self.root.append(ET.Element("sources")) 1334 for sourceObject in self.documentObject.sources: 1335 self._addSource(sourceObject) 1336 1337 if self.documentObject.variableFonts: 1338 variableFontsElement = ET.Element("variable-fonts") 1339 for variableFont in self.documentObject.variableFonts: 1340 self._addVariableFont(variableFontsElement, variableFont) 1341 self.root.append(variableFontsElement) 1342 1343 if self.documentObject.instances: 1344 self.root.append(ET.Element("instances")) 1345 for instanceObject in self.documentObject.instances: 1346 self._addInstance(instanceObject) 1347 1348 if self.documentObject.lib: 1349 self._addLib(self.root, self.documentObject.lib, 2) 1350 1351 tree = ET.ElementTree(self.root) 1352 tree.write( 1353 self.path, 1354 encoding=encoding, 1355 method='xml', 1356 xml_declaration=xml_declaration, 1357 pretty_print=pretty, 1358 ) 1359 1360 def _getEffectiveFormatTuple(self): 1361 """Try to use the version specified in the document, or a sufficiently 1362 recent version to be able to encode what the document contains. 1363 """ 1364 minVersion = self.documentObject.formatTuple 1365 if ( 1366 any( 1367 hasattr(axis, 'values') or 1368 axis.axisOrdering is not None or 1369 axis.axisLabels 1370 for axis in self.documentObject.axes 1371 ) or 1372 self.documentObject.locationLabels or 1373 any( 1374 source.localisedFamilyName 1375 for source in self.documentObject.sources 1376 ) or 1377 self.documentObject.variableFonts or 1378 any( 1379 instance.locationLabel or 1380 instance.userLocation 1381 for instance in self.documentObject.instances 1382 ) 1383 ): 1384 if minVersion < (5, 0): 1385 minVersion = (5, 0) 1386 return minVersion 1387 1388 def _makeLocationElement(self, locationObject, name=None): 1389 """ Convert Location dict to a locationElement.""" 1390 locElement = ET.Element("location") 1391 if name is not None: 1392 locElement.attrib['name'] = name 1393 validatedLocation = self.documentObject.newDefaultLocation() 1394 for axisName, axisValue in locationObject.items(): 1395 if axisName in validatedLocation: 1396 # only accept values we know 1397 validatedLocation[axisName] = axisValue 1398 for dimensionName, dimensionValue in validatedLocation.items(): 1399 dimElement = ET.Element('dimension') 1400 dimElement.attrib['name'] = dimensionName 1401 if type(dimensionValue) == tuple: 1402 dimElement.attrib['xvalue'] = self.intOrFloat(dimensionValue[0]) 1403 dimElement.attrib['yvalue'] = self.intOrFloat(dimensionValue[1]) 1404 else: 1405 dimElement.attrib['xvalue'] = self.intOrFloat(dimensionValue) 1406 locElement.append(dimElement) 1407 return locElement, validatedLocation 1408 1409 def intOrFloat(self, num): 1410 if int(num) == num: 1411 return "%d" % num 1412 return ("%f" % num).rstrip('0').rstrip('.') 1413 1414 def _addRule(self, ruleObject): 1415 # if none of the conditions have minimum or maximum values, do not add the rule. 1416 ruleElement = ET.Element('rule') 1417 if ruleObject.name is not None: 1418 ruleElement.attrib['name'] = ruleObject.name 1419 for conditions in ruleObject.conditionSets: 1420 conditionsetElement = ET.Element('conditionset') 1421 for cond in conditions: 1422 if cond.get('minimum') is None and cond.get('maximum') is None: 1423 # neither is defined, don't add this condition 1424 continue 1425 conditionElement = ET.Element('condition') 1426 conditionElement.attrib['name'] = cond.get('name') 1427 if cond.get('minimum') is not None: 1428 conditionElement.attrib['minimum'] = self.intOrFloat(cond.get('minimum')) 1429 if cond.get('maximum') is not None: 1430 conditionElement.attrib['maximum'] = self.intOrFloat(cond.get('maximum')) 1431 conditionsetElement.append(conditionElement) 1432 if len(conditionsetElement): 1433 ruleElement.append(conditionsetElement) 1434 for sub in ruleObject.subs: 1435 subElement = ET.Element('sub') 1436 subElement.attrib['name'] = sub[0] 1437 subElement.attrib['with'] = sub[1] 1438 ruleElement.append(subElement) 1439 if len(ruleElement): 1440 self.root.findall('.rules')[0].append(ruleElement) 1441 1442 def _addAxis(self, axisObject): 1443 axisElement = ET.Element('axis') 1444 axisElement.attrib['tag'] = axisObject.tag 1445 axisElement.attrib['name'] = axisObject.name 1446 self._addLabelNames(axisElement, axisObject.labelNames) 1447 if axisObject.map: 1448 for inputValue, outputValue in axisObject.map: 1449 mapElement = ET.Element('map') 1450 mapElement.attrib['input'] = self.intOrFloat(inputValue) 1451 mapElement.attrib['output'] = self.intOrFloat(outputValue) 1452 axisElement.append(mapElement) 1453 if axisObject.axisOrdering or axisObject.axisLabels: 1454 labelsElement = ET.Element('labels') 1455 if axisObject.axisOrdering is not None: 1456 labelsElement.attrib['ordering'] = str(axisObject.axisOrdering) 1457 for label in axisObject.axisLabels: 1458 self._addAxisLabel(labelsElement, label) 1459 axisElement.append(labelsElement) 1460 if hasattr(axisObject, "minimum"): 1461 axisElement.attrib['minimum'] = self.intOrFloat(axisObject.minimum) 1462 axisElement.attrib['maximum'] = self.intOrFloat(axisObject.maximum) 1463 elif hasattr(axisObject, "values"): 1464 axisElement.attrib['values'] = " ".join(self.intOrFloat(v) for v in axisObject.values) 1465 axisElement.attrib['default'] = self.intOrFloat(axisObject.default) 1466 if axisObject.hidden: 1467 axisElement.attrib['hidden'] = "1" 1468 self.root.findall('.axes')[0].append(axisElement) 1469 1470 def _addAxisLabel(self, axisElement: ET.Element, label: AxisLabelDescriptor) -> None: 1471 labelElement = ET.Element('label') 1472 labelElement.attrib['uservalue'] = self.intOrFloat(label.userValue) 1473 if label.userMinimum is not None: 1474 labelElement.attrib['userminimum'] = self.intOrFloat(label.userMinimum) 1475 if label.userMaximum is not None: 1476 labelElement.attrib['usermaximum'] = self.intOrFloat(label.userMaximum) 1477 labelElement.attrib['name'] = label.name 1478 if label.elidable: 1479 labelElement.attrib['elidable'] = "true" 1480 if label.olderSibling: 1481 labelElement.attrib['oldersibling'] = "true" 1482 if label.linkedUserValue is not None: 1483 labelElement.attrib['linkeduservalue'] = self.intOrFloat(label.linkedUserValue) 1484 self._addLabelNames(labelElement, label.labelNames) 1485 axisElement.append(labelElement) 1486 1487 def _addLabelNames(self, parentElement, labelNames): 1488 for languageCode, labelName in sorted(labelNames.items()): 1489 languageElement = ET.Element('labelname') 1490 languageElement.attrib[XML_LANG] = languageCode 1491 languageElement.text = labelName 1492 parentElement.append(languageElement) 1493 1494 def _addLocationLabel(self, parentElement: ET.Element, label: LocationLabelDescriptor) -> None: 1495 labelElement = ET.Element('label') 1496 labelElement.attrib['name'] = label.name 1497 if label.elidable: 1498 labelElement.attrib['elidable'] = "true" 1499 if label.olderSibling: 1500 labelElement.attrib['oldersibling'] = "true" 1501 self._addLabelNames(labelElement, label.labelNames) 1502 self._addLocationElement(labelElement, userLocation=label.userLocation) 1503 parentElement.append(labelElement) 1504 1505 def _addLocationElement( 1506 self, 1507 parentElement, 1508 *, 1509 designLocation: AnisotropicLocationDict = None, 1510 userLocation: SimpleLocationDict = None 1511 ): 1512 locElement = ET.Element("location") 1513 for axis in self.documentObject.axes: 1514 if designLocation is not None and axis.name in designLocation: 1515 dimElement = ET.Element('dimension') 1516 dimElement.attrib['name'] = axis.name 1517 value = designLocation[axis.name] 1518 if isinstance(value, tuple): 1519 dimElement.attrib['xvalue'] = self.intOrFloat(value[0]) 1520 dimElement.attrib['yvalue'] = self.intOrFloat(value[1]) 1521 else: 1522 dimElement.attrib['xvalue'] = self.intOrFloat(value) 1523 locElement.append(dimElement) 1524 elif userLocation is not None and axis.name in userLocation: 1525 dimElement = ET.Element('dimension') 1526 dimElement.attrib['name'] = axis.name 1527 value = userLocation[axis.name] 1528 dimElement.attrib['uservalue'] = self.intOrFloat(value) 1529 locElement.append(dimElement) 1530 if len(locElement) > 0: 1531 parentElement.append(locElement) 1532 1533 def _addInstance(self, instanceObject): 1534 instanceElement = ET.Element('instance') 1535 if instanceObject.name is not None: 1536 instanceElement.attrib['name'] = instanceObject.name 1537 if instanceObject.locationLabel is not None: 1538 instanceElement.attrib['location'] = instanceObject.locationLabel 1539 if instanceObject.familyName is not None: 1540 instanceElement.attrib['familyname'] = instanceObject.familyName 1541 if instanceObject.styleName is not None: 1542 instanceElement.attrib['stylename'] = instanceObject.styleName 1543 # add localisations 1544 if instanceObject.localisedStyleName: 1545 languageCodes = list(instanceObject.localisedStyleName.keys()) 1546 languageCodes.sort() 1547 for code in languageCodes: 1548 if code == "en": 1549 continue # already stored in the element attribute 1550 localisedStyleNameElement = ET.Element('stylename') 1551 localisedStyleNameElement.attrib[XML_LANG] = code 1552 localisedStyleNameElement.text = instanceObject.getStyleName(code) 1553 instanceElement.append(localisedStyleNameElement) 1554 if instanceObject.localisedFamilyName: 1555 languageCodes = list(instanceObject.localisedFamilyName.keys()) 1556 languageCodes.sort() 1557 for code in languageCodes: 1558 if code == "en": 1559 continue # already stored in the element attribute 1560 localisedFamilyNameElement = ET.Element('familyname') 1561 localisedFamilyNameElement.attrib[XML_LANG] = code 1562 localisedFamilyNameElement.text = instanceObject.getFamilyName(code) 1563 instanceElement.append(localisedFamilyNameElement) 1564 if instanceObject.localisedStyleMapStyleName: 1565 languageCodes = list(instanceObject.localisedStyleMapStyleName.keys()) 1566 languageCodes.sort() 1567 for code in languageCodes: 1568 if code == "en": 1569 continue 1570 localisedStyleMapStyleNameElement = ET.Element('stylemapstylename') 1571 localisedStyleMapStyleNameElement.attrib[XML_LANG] = code 1572 localisedStyleMapStyleNameElement.text = instanceObject.getStyleMapStyleName(code) 1573 instanceElement.append(localisedStyleMapStyleNameElement) 1574 if instanceObject.localisedStyleMapFamilyName: 1575 languageCodes = list(instanceObject.localisedStyleMapFamilyName.keys()) 1576 languageCodes.sort() 1577 for code in languageCodes: 1578 if code == "en": 1579 continue 1580 localisedStyleMapFamilyNameElement = ET.Element('stylemapfamilyname') 1581 localisedStyleMapFamilyNameElement.attrib[XML_LANG] = code 1582 localisedStyleMapFamilyNameElement.text = instanceObject.getStyleMapFamilyName(code) 1583 instanceElement.append(localisedStyleMapFamilyNameElement) 1584 1585 if self.effectiveFormatTuple >= (5, 0): 1586 if instanceObject.locationLabel is None: 1587 self._addLocationElement( 1588 instanceElement, 1589 designLocation=instanceObject.designLocation, 1590 userLocation=instanceObject.userLocation 1591 ) 1592 else: 1593 # Pre-version 5.0 code was validating and filling in the location 1594 # dict while writing it out, as preserved below. 1595 if instanceObject.location is not None: 1596 locationElement, instanceObject.location = self._makeLocationElement(instanceObject.location) 1597 instanceElement.append(locationElement) 1598 if instanceObject.filename is not None: 1599 instanceElement.attrib['filename'] = instanceObject.filename 1600 if instanceObject.postScriptFontName is not None: 1601 instanceElement.attrib['postscriptfontname'] = instanceObject.postScriptFontName 1602 if instanceObject.styleMapFamilyName is not None: 1603 instanceElement.attrib['stylemapfamilyname'] = instanceObject.styleMapFamilyName 1604 if instanceObject.styleMapStyleName is not None: 1605 instanceElement.attrib['stylemapstylename'] = instanceObject.styleMapStyleName 1606 if self.effectiveFormatTuple < (5, 0): 1607 # Deprecated members as of version 5.0 1608 if instanceObject.glyphs: 1609 if instanceElement.findall('.glyphs') == []: 1610 glyphsElement = ET.Element('glyphs') 1611 instanceElement.append(glyphsElement) 1612 glyphsElement = instanceElement.findall('.glyphs')[0] 1613 for glyphName, data in sorted(instanceObject.glyphs.items()): 1614 glyphElement = self._writeGlyphElement(instanceElement, instanceObject, glyphName, data) 1615 glyphsElement.append(glyphElement) 1616 if instanceObject.kerning: 1617 kerningElement = ET.Element('kerning') 1618 instanceElement.append(kerningElement) 1619 if instanceObject.info: 1620 infoElement = ET.Element('info') 1621 instanceElement.append(infoElement) 1622 self._addLib(instanceElement, instanceObject.lib, 4) 1623 self.root.findall('.instances')[0].append(instanceElement) 1624 1625 def _addSource(self, sourceObject): 1626 sourceElement = ET.Element("source") 1627 if sourceObject.filename is not None: 1628 sourceElement.attrib['filename'] = sourceObject.filename 1629 if sourceObject.name is not None: 1630 if sourceObject.name.find("temp_master") != 0: 1631 # do not save temporary source names 1632 sourceElement.attrib['name'] = sourceObject.name 1633 if sourceObject.familyName is not None: 1634 sourceElement.attrib['familyname'] = sourceObject.familyName 1635 if sourceObject.styleName is not None: 1636 sourceElement.attrib['stylename'] = sourceObject.styleName 1637 if sourceObject.layerName is not None: 1638 sourceElement.attrib['layer'] = sourceObject.layerName 1639 if sourceObject.localisedFamilyName: 1640 languageCodes = list(sourceObject.localisedFamilyName.keys()) 1641 languageCodes.sort() 1642 for code in languageCodes: 1643 if code == "en": 1644 continue # already stored in the element attribute 1645 localisedFamilyNameElement = ET.Element('familyname') 1646 localisedFamilyNameElement.attrib[XML_LANG] = code 1647 localisedFamilyNameElement.text = sourceObject.getFamilyName(code) 1648 sourceElement.append(localisedFamilyNameElement) 1649 if sourceObject.copyLib: 1650 libElement = ET.Element('lib') 1651 libElement.attrib['copy'] = "1" 1652 sourceElement.append(libElement) 1653 if sourceObject.copyGroups: 1654 groupsElement = ET.Element('groups') 1655 groupsElement.attrib['copy'] = "1" 1656 sourceElement.append(groupsElement) 1657 if sourceObject.copyFeatures: 1658 featuresElement = ET.Element('features') 1659 featuresElement.attrib['copy'] = "1" 1660 sourceElement.append(featuresElement) 1661 if sourceObject.copyInfo or sourceObject.muteInfo: 1662 infoElement = ET.Element('info') 1663 if sourceObject.copyInfo: 1664 infoElement.attrib['copy'] = "1" 1665 if sourceObject.muteInfo: 1666 infoElement.attrib['mute'] = "1" 1667 sourceElement.append(infoElement) 1668 if sourceObject.muteKerning: 1669 kerningElement = ET.Element("kerning") 1670 kerningElement.attrib["mute"] = '1' 1671 sourceElement.append(kerningElement) 1672 if sourceObject.mutedGlyphNames: 1673 for name in sourceObject.mutedGlyphNames: 1674 glyphElement = ET.Element("glyph") 1675 glyphElement.attrib["name"] = name 1676 glyphElement.attrib["mute"] = '1' 1677 sourceElement.append(glyphElement) 1678 if self.effectiveFormatTuple >= (5, 0): 1679 self._addLocationElement(sourceElement, designLocation=sourceObject.location) 1680 else: 1681 # Pre-version 5.0 code was validating and filling in the location 1682 # dict while writing it out, as preserved below. 1683 locationElement, sourceObject.location = self._makeLocationElement(sourceObject.location) 1684 sourceElement.append(locationElement) 1685 self.root.findall('.sources')[0].append(sourceElement) 1686 1687 def _addVariableFont(self, parentElement: ET.Element, vf: VariableFontDescriptor) -> None: 1688 vfElement = ET.Element('variable-font') 1689 vfElement.attrib['name'] = vf.name 1690 if vf.filename is not None: 1691 vfElement.attrib['filename'] = vf.filename 1692 if vf.axisSubsets: 1693 subsetsElement = ET.Element('axis-subsets') 1694 for subset in vf.axisSubsets: 1695 subsetElement = ET.Element('axis-subset') 1696 subsetElement.attrib['name'] = subset.name 1697 # Mypy doesn't support narrowing union types via hasattr() 1698 # https://mypy.readthedocs.io/en/stable/type_narrowing.html 1699 # TODO(Python 3.10): use TypeGuard 1700 if hasattr(subset, "userMinimum"): 1701 subset = cast(RangeAxisSubsetDescriptor, subset) 1702 if subset.userMinimum != -math.inf: 1703 subsetElement.attrib['userminimum'] = self.intOrFloat(subset.userMinimum) 1704 if subset.userMaximum != math.inf: 1705 subsetElement.attrib['usermaximum'] = self.intOrFloat(subset.userMaximum) 1706 if subset.userDefault is not None: 1707 subsetElement.attrib['userdefault'] = self.intOrFloat(subset.userDefault) 1708 elif hasattr(subset, "userValue"): 1709 subset = cast(ValueAxisSubsetDescriptor, subset) 1710 subsetElement.attrib['uservalue'] = self.intOrFloat(subset.userValue) 1711 subsetsElement.append(subsetElement) 1712 vfElement.append(subsetsElement) 1713 self._addLib(vfElement, vf.lib, 4) 1714 parentElement.append(vfElement) 1715 1716 def _addLib(self, parentElement: ET.Element, data: Any, indent_level: int) -> None: 1717 if not data: 1718 return 1719 libElement = ET.Element('lib') 1720 libElement.append(plistlib.totree(data, indent_level=indent_level)) 1721 parentElement.append(libElement) 1722 1723 def _writeGlyphElement(self, instanceElement, instanceObject, glyphName, data): 1724 glyphElement = ET.Element('glyph') 1725 if data.get('mute'): 1726 glyphElement.attrib['mute'] = "1" 1727 if data.get('unicodes') is not None: 1728 glyphElement.attrib['unicode'] = " ".join([hex(u) for u in data.get('unicodes')]) 1729 if data.get('instanceLocation') is not None: 1730 locationElement, data['instanceLocation'] = self._makeLocationElement(data.get('instanceLocation')) 1731 glyphElement.append(locationElement) 1732 if glyphName is not None: 1733 glyphElement.attrib['name'] = glyphName 1734 if data.get('note') is not None: 1735 noteElement = ET.Element('note') 1736 noteElement.text = data.get('note') 1737 glyphElement.append(noteElement) 1738 if data.get('masters') is not None: 1739 mastersElement = ET.Element("masters") 1740 for m in data.get('masters'): 1741 masterElement = ET.Element("master") 1742 if m.get('glyphName') is not None: 1743 masterElement.attrib['glyphname'] = m.get('glyphName') 1744 if m.get('font') is not None: 1745 masterElement.attrib['source'] = m.get('font') 1746 if m.get('location') is not None: 1747 locationElement, m['location'] = self._makeLocationElement(m.get('location')) 1748 masterElement.append(locationElement) 1749 mastersElement.append(masterElement) 1750 glyphElement.append(mastersElement) 1751 return glyphElement 1752 1753 1754class BaseDocReader(LogMixin): 1755 axisDescriptorClass = AxisDescriptor 1756 discreteAxisDescriptorClass = DiscreteAxisDescriptor 1757 axisLabelDescriptorClass = AxisLabelDescriptor 1758 locationLabelDescriptorClass = LocationLabelDescriptor 1759 ruleDescriptorClass = RuleDescriptor 1760 sourceDescriptorClass = SourceDescriptor 1761 variableFontsDescriptorClass = VariableFontDescriptor 1762 valueAxisSubsetDescriptorClass = ValueAxisSubsetDescriptor 1763 rangeAxisSubsetDescriptorClass = RangeAxisSubsetDescriptor 1764 instanceDescriptorClass = InstanceDescriptor 1765 1766 def __init__(self, documentPath, documentObject): 1767 self.path = documentPath 1768 self.documentObject = documentObject 1769 tree = ET.parse(self.path) 1770 self.root = tree.getroot() 1771 self.documentObject.formatVersion = self.root.attrib.get("format", "3.0") 1772 self._axes = [] 1773 self.rules = [] 1774 self.sources = [] 1775 self.instances = [] 1776 self.axisDefaults = {} 1777 self._strictAxisNames = True 1778 1779 @classmethod 1780 def fromstring(cls, string, documentObject): 1781 f = BytesIO(tobytes(string, encoding="utf-8")) 1782 self = cls(f, documentObject) 1783 self.path = None 1784 return self 1785 1786 def read(self): 1787 self.readAxes() 1788 self.readLabels() 1789 self.readRules() 1790 self.readVariableFonts() 1791 self.readSources() 1792 self.readInstances() 1793 self.readLib() 1794 1795 def readRules(self): 1796 # we also need to read any conditions that are outside of a condition set. 1797 rules = [] 1798 rulesElement = self.root.find(".rules") 1799 if rulesElement is not None: 1800 processingValue = rulesElement.attrib.get("processing", "first") 1801 if processingValue not in {"first", "last"}: 1802 raise DesignSpaceDocumentError( 1803 "<rules> processing attribute value is not valid: %r, " 1804 "expected 'first' or 'last'" % processingValue) 1805 self.documentObject.rulesProcessingLast = processingValue == "last" 1806 for ruleElement in self.root.findall(".rules/rule"): 1807 ruleObject = self.ruleDescriptorClass() 1808 ruleName = ruleObject.name = ruleElement.attrib.get("name") 1809 # read any stray conditions outside a condition set 1810 externalConditions = self._readConditionElements( 1811 ruleElement, 1812 ruleName, 1813 ) 1814 if externalConditions: 1815 ruleObject.conditionSets.append(externalConditions) 1816 self.log.info( 1817 "Found stray rule conditions outside a conditionset. " 1818 "Wrapped them in a new conditionset." 1819 ) 1820 # read the conditionsets 1821 for conditionSetElement in ruleElement.findall('.conditionset'): 1822 conditionSet = self._readConditionElements( 1823 conditionSetElement, 1824 ruleName, 1825 ) 1826 if conditionSet is not None: 1827 ruleObject.conditionSets.append(conditionSet) 1828 for subElement in ruleElement.findall('.sub'): 1829 a = subElement.attrib['name'] 1830 b = subElement.attrib['with'] 1831 ruleObject.subs.append((a, b)) 1832 rules.append(ruleObject) 1833 self.documentObject.rules = rules 1834 1835 def _readConditionElements(self, parentElement, ruleName=None): 1836 cds = [] 1837 for conditionElement in parentElement.findall('.condition'): 1838 cd = {} 1839 cdMin = conditionElement.attrib.get("minimum") 1840 if cdMin is not None: 1841 cd['minimum'] = float(cdMin) 1842 else: 1843 # will allow these to be None, assume axis.minimum 1844 cd['minimum'] = None 1845 cdMax = conditionElement.attrib.get("maximum") 1846 if cdMax is not None: 1847 cd['maximum'] = float(cdMax) 1848 else: 1849 # will allow these to be None, assume axis.maximum 1850 cd['maximum'] = None 1851 cd['name'] = conditionElement.attrib.get("name") 1852 # # test for things 1853 if cd.get('minimum') is None and cd.get('maximum') is None: 1854 raise DesignSpaceDocumentError( 1855 "condition missing required minimum or maximum in rule" + 1856 (" '%s'" % ruleName if ruleName is not None else "")) 1857 cds.append(cd) 1858 return cds 1859 1860 def readAxes(self): 1861 # read the axes elements, including the warp map. 1862 axesElement = self.root.find(".axes") 1863 if axesElement is not None and 'elidedfallbackname' in axesElement.attrib: 1864 self.documentObject.elidedFallbackName = axesElement.attrib['elidedfallbackname'] 1865 axisElements = self.root.findall(".axes/axis") 1866 if not axisElements: 1867 return 1868 for axisElement in axisElements: 1869 if self.documentObject.formatTuple >= (5, 0) and "values" in axisElement.attrib: 1870 axisObject = self.discreteAxisDescriptorClass() 1871 axisObject.values = [float(s) for s in axisElement.attrib["values"].split(" ")] 1872 else: 1873 axisObject = self.axisDescriptorClass() 1874 axisObject.minimum = float(axisElement.attrib.get("minimum")) 1875 axisObject.maximum = float(axisElement.attrib.get("maximum")) 1876 axisObject.default = float(axisElement.attrib.get("default")) 1877 axisObject.name = axisElement.attrib.get("name") 1878 if axisElement.attrib.get('hidden', False): 1879 axisObject.hidden = True 1880 axisObject.tag = axisElement.attrib.get("tag") 1881 for mapElement in axisElement.findall('map'): 1882 a = float(mapElement.attrib['input']) 1883 b = float(mapElement.attrib['output']) 1884 axisObject.map.append((a, b)) 1885 for labelNameElement in axisElement.findall('labelname'): 1886 # Note: elementtree reads the "xml:lang" attribute name as 1887 # '{http://www.w3.org/XML/1998/namespace}lang' 1888 for key, lang in labelNameElement.items(): 1889 if key == XML_LANG: 1890 axisObject.labelNames[lang] = tostr(labelNameElement.text) 1891 labelElement = axisElement.find(".labels") 1892 if labelElement is not None: 1893 if "ordering" in labelElement.attrib: 1894 axisObject.axisOrdering = int(labelElement.attrib["ordering"]) 1895 for label in labelElement.findall(".label"): 1896 axisObject.axisLabels.append(self.readAxisLabel(label)) 1897 self.documentObject.axes.append(axisObject) 1898 self.axisDefaults[axisObject.name] = axisObject.default 1899 1900 def readAxisLabel(self, element: ET.Element): 1901 xml_attrs = {'userminimum', 'uservalue', 'usermaximum', 'name', 'elidable', 'oldersibling', 'linkeduservalue'} 1902 unknown_attrs = set(element.attrib) - xml_attrs 1903 if unknown_attrs: 1904 raise DesignSpaceDocumentError(f"label element contains unknown attributes: {', '.join(unknown_attrs)}") 1905 1906 name = element.get("name") 1907 if name is None: 1908 raise DesignSpaceDocumentError("label element must have a name attribute.") 1909 valueStr = element.get("uservalue") 1910 if valueStr is None: 1911 raise DesignSpaceDocumentError("label element must have a uservalue attribute.") 1912 value = float(valueStr) 1913 minimumStr = element.get("userminimum") 1914 minimum = float(minimumStr) if minimumStr is not None else None 1915 maximumStr = element.get("usermaximum") 1916 maximum = float(maximumStr) if maximumStr is not None else None 1917 linkedValueStr = element.get("linkeduservalue") 1918 linkedValue = float(linkedValueStr) if linkedValueStr is not None else None 1919 elidable = True if element.get("elidable") == "true" else False 1920 olderSibling = True if element.get("oldersibling") == "true" else False 1921 labelNames = { 1922 lang: label_name.text or "" 1923 for label_name in element.findall("labelname") 1924 for attr, lang in label_name.items() 1925 if attr == XML_LANG 1926 # Note: elementtree reads the "xml:lang" attribute name as 1927 # '{http://www.w3.org/XML/1998/namespace}lang' 1928 } 1929 return self.axisLabelDescriptorClass( 1930 name=name, 1931 userValue=value, 1932 userMinimum=minimum, 1933 userMaximum=maximum, 1934 elidable=elidable, 1935 olderSibling=olderSibling, 1936 linkedUserValue=linkedValue, 1937 labelNames=labelNames, 1938 ) 1939 1940 def readLabels(self): 1941 if self.documentObject.formatTuple < (5, 0): 1942 return 1943 1944 xml_attrs = {'name', 'elidable', 'oldersibling'} 1945 for labelElement in self.root.findall(".labels/label"): 1946 unknown_attrs = set(labelElement.attrib) - xml_attrs 1947 if unknown_attrs: 1948 raise DesignSpaceDocumentError(f"Label element contains unknown attributes: {', '.join(unknown_attrs)}") 1949 1950 name = labelElement.get("name") 1951 if name is None: 1952 raise DesignSpaceDocumentError("label element must have a name attribute.") 1953 designLocation, userLocation = self.locationFromElement(labelElement) 1954 if designLocation: 1955 raise DesignSpaceDocumentError(f'<label> element "{name}" must only have user locations (using uservalue="").') 1956 elidable = True if labelElement.get("elidable") == "true" else False 1957 olderSibling = True if labelElement.get("oldersibling") == "true" else False 1958 labelNames = { 1959 lang: label_name.text or "" 1960 for label_name in labelElement.findall("labelname") 1961 for attr, lang in label_name.items() 1962 if attr == XML_LANG 1963 # Note: elementtree reads the "xml:lang" attribute name as 1964 # '{http://www.w3.org/XML/1998/namespace}lang' 1965 } 1966 locationLabel = self.locationLabelDescriptorClass( 1967 name=name, 1968 userLocation=userLocation, 1969 elidable=elidable, 1970 olderSibling=olderSibling, 1971 labelNames=labelNames, 1972 ) 1973 self.documentObject.locationLabels.append(locationLabel) 1974 1975 def readVariableFonts(self): 1976 if self.documentObject.formatTuple < (5, 0): 1977 return 1978 1979 xml_attrs = {'name', 'filename'} 1980 for variableFontElement in self.root.findall(".variable-fonts/variable-font"): 1981 unknown_attrs = set(variableFontElement.attrib) - xml_attrs 1982 if unknown_attrs: 1983 raise DesignSpaceDocumentError(f"variable-font element contains unknown attributes: {', '.join(unknown_attrs)}") 1984 1985 name = variableFontElement.get("name") 1986 if name is None: 1987 raise DesignSpaceDocumentError("variable-font element must have a name attribute.") 1988 1989 filename = variableFontElement.get("filename") 1990 1991 axisSubsetsElement = variableFontElement.find(".axis-subsets") 1992 if axisSubsetsElement is None: 1993 raise DesignSpaceDocumentError("variable-font element must contain an axis-subsets element.") 1994 axisSubsets = [] 1995 for axisSubset in axisSubsetsElement.iterfind(".axis-subset"): 1996 axisSubsets.append(self.readAxisSubset(axisSubset)) 1997 1998 lib = None 1999 libElement = variableFontElement.find(".lib") 2000 if libElement is not None: 2001 lib = plistlib.fromtree(libElement[0]) 2002 2003 variableFont = self.variableFontsDescriptorClass( 2004 name=name, 2005 filename=filename, 2006 axisSubsets=axisSubsets, 2007 lib=lib, 2008 ) 2009 self.documentObject.variableFonts.append(variableFont) 2010 2011 def readAxisSubset(self, element: ET.Element): 2012 if "uservalue" in element.attrib: 2013 xml_attrs = {'name', 'uservalue'} 2014 unknown_attrs = set(element.attrib) - xml_attrs 2015 if unknown_attrs: 2016 raise DesignSpaceDocumentError(f"axis-subset element contains unknown attributes: {', '.join(unknown_attrs)}") 2017 2018 name = element.get("name") 2019 if name is None: 2020 raise DesignSpaceDocumentError("axis-subset element must have a name attribute.") 2021 userValueStr = element.get("uservalue") 2022 if userValueStr is None: 2023 raise DesignSpaceDocumentError( 2024 "The axis-subset element for a discrete subset must have a uservalue attribute." 2025 ) 2026 userValue = float(userValueStr) 2027 2028 return self.valueAxisSubsetDescriptorClass(name=name, userValue=userValue) 2029 else: 2030 xml_attrs = {'name', 'userminimum', 'userdefault', 'usermaximum'} 2031 unknown_attrs = set(element.attrib) - xml_attrs 2032 if unknown_attrs: 2033 raise DesignSpaceDocumentError(f"axis-subset element contains unknown attributes: {', '.join(unknown_attrs)}") 2034 2035 name = element.get("name") 2036 if name is None: 2037 raise DesignSpaceDocumentError("axis-subset element must have a name attribute.") 2038 2039 userMinimum = element.get("userminimum") 2040 userDefault = element.get("userdefault") 2041 userMaximum = element.get("usermaximum") 2042 if userMinimum is not None and userDefault is not None and userMaximum is not None: 2043 return self.rangeAxisSubsetDescriptorClass( 2044 name=name, 2045 userMinimum=float(userMinimum), 2046 userDefault=float(userDefault), 2047 userMaximum=float(userMaximum), 2048 ) 2049 if all(v is None for v in (userMinimum, userDefault, userMaximum)): 2050 return self.rangeAxisSubsetDescriptorClass(name=name) 2051 2052 raise DesignSpaceDocumentError( 2053 "axis-subset element must have min/max/default values or none at all." 2054 ) 2055 2056 2057 def readSources(self): 2058 for sourceCount, sourceElement in enumerate(self.root.findall(".sources/source")): 2059 filename = sourceElement.attrib.get('filename') 2060 if filename is not None and self.path is not None: 2061 sourcePath = os.path.abspath(os.path.join(os.path.dirname(self.path), filename)) 2062 else: 2063 sourcePath = None 2064 sourceName = sourceElement.attrib.get('name') 2065 if sourceName is None: 2066 # add a temporary source name 2067 sourceName = "temp_master.%d" % (sourceCount) 2068 sourceObject = self.sourceDescriptorClass() 2069 sourceObject.path = sourcePath # absolute path to the ufo source 2070 sourceObject.filename = filename # path as it is stored in the document 2071 sourceObject.name = sourceName 2072 familyName = sourceElement.attrib.get("familyname") 2073 if familyName is not None: 2074 sourceObject.familyName = familyName 2075 styleName = sourceElement.attrib.get("stylename") 2076 if styleName is not None: 2077 sourceObject.styleName = styleName 2078 for familyNameElement in sourceElement.findall('familyname'): 2079 for key, lang in familyNameElement.items(): 2080 if key == XML_LANG: 2081 familyName = familyNameElement.text 2082 sourceObject.setFamilyName(familyName, lang) 2083 designLocation, userLocation = self.locationFromElement(sourceElement) 2084 if userLocation: 2085 raise DesignSpaceDocumentError(f'<source> element "{sourceName}" must only have design locations (using xvalue="").') 2086 sourceObject.location = designLocation 2087 layerName = sourceElement.attrib.get('layer') 2088 if layerName is not None: 2089 sourceObject.layerName = layerName 2090 for libElement in sourceElement.findall('.lib'): 2091 if libElement.attrib.get('copy') == '1': 2092 sourceObject.copyLib = True 2093 for groupsElement in sourceElement.findall('.groups'): 2094 if groupsElement.attrib.get('copy') == '1': 2095 sourceObject.copyGroups = True 2096 for infoElement in sourceElement.findall(".info"): 2097 if infoElement.attrib.get('copy') == '1': 2098 sourceObject.copyInfo = True 2099 if infoElement.attrib.get('mute') == '1': 2100 sourceObject.muteInfo = True 2101 for featuresElement in sourceElement.findall(".features"): 2102 if featuresElement.attrib.get('copy') == '1': 2103 sourceObject.copyFeatures = True 2104 for glyphElement in sourceElement.findall(".glyph"): 2105 glyphName = glyphElement.attrib.get('name') 2106 if glyphName is None: 2107 continue 2108 if glyphElement.attrib.get('mute') == '1': 2109 sourceObject.mutedGlyphNames.append(glyphName) 2110 for kerningElement in sourceElement.findall(".kerning"): 2111 if kerningElement.attrib.get('mute') == '1': 2112 sourceObject.muteKerning = True 2113 self.documentObject.sources.append(sourceObject) 2114 2115 def locationFromElement(self, element): 2116 """Read a nested ``<location>`` element inside the given ``element``. 2117 2118 .. versionchanged:: 5.0 2119 Return a tuple of (designLocation, userLocation) 2120 """ 2121 elementLocation = (None, None) 2122 for locationElement in element.findall('.location'): 2123 elementLocation = self.readLocationElement(locationElement) 2124 break 2125 return elementLocation 2126 2127 def readLocationElement(self, locationElement): 2128 """Read a ``<location>`` element. 2129 2130 .. versionchanged:: 5.0 2131 Return a tuple of (designLocation, userLocation) 2132 """ 2133 if self._strictAxisNames and not self.documentObject.axes: 2134 raise DesignSpaceDocumentError("No axes defined") 2135 userLoc = {} 2136 designLoc = {} 2137 for dimensionElement in locationElement.findall(".dimension"): 2138 dimName = dimensionElement.attrib.get("name") 2139 if self._strictAxisNames and dimName not in self.axisDefaults: 2140 # In case the document contains no axis definitions, 2141 self.log.warning("Location with undefined axis: \"%s\".", dimName) 2142 continue 2143 userValue = xValue = yValue = None 2144 try: 2145 userValue = dimensionElement.attrib.get('uservalue') 2146 if userValue is not None: 2147 userValue = float(userValue) 2148 except ValueError: 2149 self.log.warning("ValueError in readLocation userValue %3.3f", userValue) 2150 try: 2151 xValue = dimensionElement.attrib.get('xvalue') 2152 if xValue is not None: 2153 xValue = float(xValue) 2154 except ValueError: 2155 self.log.warning("ValueError in readLocation xValue %3.3f", xValue) 2156 try: 2157 yValue = dimensionElement.attrib.get('yvalue') 2158 if yValue is not None: 2159 yValue = float(yValue) 2160 except ValueError: 2161 self.log.warning("ValueError in readLocation yValue %3.3f", yValue) 2162 if userValue is None == xValue is None: 2163 raise DesignSpaceDocumentError(f'Exactly one of uservalue="" or xvalue="" must be provided for location dimension "{dimName}"') 2164 if yValue is not None: 2165 if xValue is None: 2166 raise DesignSpaceDocumentError(f'Missing xvalue="" for the location dimension "{dimName}"" with yvalue="{yValue}"') 2167 designLoc[dimName] = (xValue, yValue) 2168 elif xValue is not None: 2169 designLoc[dimName] = xValue 2170 else: 2171 userLoc[dimName] = userValue 2172 return designLoc, userLoc 2173 2174 def readInstances(self, makeGlyphs=True, makeKerning=True, makeInfo=True): 2175 instanceElements = self.root.findall('.instances/instance') 2176 for instanceElement in instanceElements: 2177 self._readSingleInstanceElement(instanceElement, makeGlyphs=makeGlyphs, makeKerning=makeKerning, makeInfo=makeInfo) 2178 2179 def _readSingleInstanceElement(self, instanceElement, makeGlyphs=True, makeKerning=True, makeInfo=True): 2180 filename = instanceElement.attrib.get('filename') 2181 if filename is not None and self.documentObject.path is not None: 2182 instancePath = os.path.join(os.path.dirname(self.documentObject.path), filename) 2183 else: 2184 instancePath = None 2185 instanceObject = self.instanceDescriptorClass() 2186 instanceObject.path = instancePath # absolute path to the instance 2187 instanceObject.filename = filename # path as it is stored in the document 2188 name = instanceElement.attrib.get("name") 2189 if name is not None: 2190 instanceObject.name = name 2191 familyname = instanceElement.attrib.get('familyname') 2192 if familyname is not None: 2193 instanceObject.familyName = familyname 2194 stylename = instanceElement.attrib.get('stylename') 2195 if stylename is not None: 2196 instanceObject.styleName = stylename 2197 postScriptFontName = instanceElement.attrib.get('postscriptfontname') 2198 if postScriptFontName is not None: 2199 instanceObject.postScriptFontName = postScriptFontName 2200 styleMapFamilyName = instanceElement.attrib.get('stylemapfamilyname') 2201 if styleMapFamilyName is not None: 2202 instanceObject.styleMapFamilyName = styleMapFamilyName 2203 styleMapStyleName = instanceElement.attrib.get('stylemapstylename') 2204 if styleMapStyleName is not None: 2205 instanceObject.styleMapStyleName = styleMapStyleName 2206 # read localised names 2207 for styleNameElement in instanceElement.findall('stylename'): 2208 for key, lang in styleNameElement.items(): 2209 if key == XML_LANG: 2210 styleName = styleNameElement.text 2211 instanceObject.setStyleName(styleName, lang) 2212 for familyNameElement in instanceElement.findall('familyname'): 2213 for key, lang in familyNameElement.items(): 2214 if key == XML_LANG: 2215 familyName = familyNameElement.text 2216 instanceObject.setFamilyName(familyName, lang) 2217 for styleMapStyleNameElement in instanceElement.findall('stylemapstylename'): 2218 for key, lang in styleMapStyleNameElement.items(): 2219 if key == XML_LANG: 2220 styleMapStyleName = styleMapStyleNameElement.text 2221 instanceObject.setStyleMapStyleName(styleMapStyleName, lang) 2222 for styleMapFamilyNameElement in instanceElement.findall('stylemapfamilyname'): 2223 for key, lang in styleMapFamilyNameElement.items(): 2224 if key == XML_LANG: 2225 styleMapFamilyName = styleMapFamilyNameElement.text 2226 instanceObject.setStyleMapFamilyName(styleMapFamilyName, lang) 2227 designLocation, userLocation = self.locationFromElement(instanceElement) 2228 locationLabel = instanceElement.attrib.get('location') 2229 if (designLocation or userLocation) and locationLabel is not None: 2230 raise DesignSpaceDocumentError('instance element must have at most one of the location="..." attribute or the nested location element') 2231 instanceObject.locationLabel = locationLabel 2232 instanceObject.userLocation = userLocation or {} 2233 instanceObject.designLocation = designLocation or {} 2234 for glyphElement in instanceElement.findall('.glyphs/glyph'): 2235 self.readGlyphElement(glyphElement, instanceObject) 2236 for infoElement in instanceElement.findall("info"): 2237 self.readInfoElement(infoElement, instanceObject) 2238 for libElement in instanceElement.findall('lib'): 2239 self.readLibElement(libElement, instanceObject) 2240 self.documentObject.instances.append(instanceObject) 2241 2242 def readLibElement(self, libElement, instanceObject): 2243 """Read the lib element for the given instance.""" 2244 instanceObject.lib = plistlib.fromtree(libElement[0]) 2245 2246 def readInfoElement(self, infoElement, instanceObject): 2247 """ Read the info element.""" 2248 instanceObject.info = True 2249 2250 def readGlyphElement(self, glyphElement, instanceObject): 2251 """ 2252 Read the glyph element, which could look like either one of these: 2253 2254 .. code-block:: xml 2255 2256 <glyph name="b" unicode="0x62"/> 2257 2258 <glyph name="b"/> 2259 2260 <glyph name="b"> 2261 <master location="location-token-bbb" source="master-token-aaa2"/> 2262 <master glyphname="b.alt1" location="location-token-ccc" source="master-token-aaa3"/> 2263 <note> 2264 This is an instance from an anisotropic interpolation. 2265 </note> 2266 </glyph> 2267 """ 2268 glyphData = {} 2269 glyphName = glyphElement.attrib.get('name') 2270 if glyphName is None: 2271 raise DesignSpaceDocumentError("Glyph object without name attribute") 2272 mute = glyphElement.attrib.get("mute") 2273 if mute == "1": 2274 glyphData['mute'] = True 2275 # unicode 2276 unicodes = glyphElement.attrib.get('unicode') 2277 if unicodes is not None: 2278 try: 2279 unicodes = [int(u, 16) for u in unicodes.split(" ")] 2280 glyphData['unicodes'] = unicodes 2281 except ValueError: 2282 raise DesignSpaceDocumentError("unicode values %s are not integers" % unicodes) 2283 2284 for noteElement in glyphElement.findall('.note'): 2285 glyphData['note'] = noteElement.text 2286 break 2287 designLocation, userLocation = self.locationFromElement(glyphElement) 2288 if userLocation: 2289 raise DesignSpaceDocumentError(f'<glyph> element "{glyphName}" must only have design locations (using xvalue="").') 2290 if designLocation is not None: 2291 glyphData['instanceLocation'] = designLocation 2292 glyphSources = None 2293 for masterElement in glyphElement.findall('.masters/master'): 2294 fontSourceName = masterElement.attrib.get('source') 2295 designLocation, userLocation = self.locationFromElement(masterElement) 2296 if userLocation: 2297 raise DesignSpaceDocumentError(f'<master> element "{fontSourceName}" must only have design locations (using xvalue="").') 2298 masterGlyphName = masterElement.attrib.get('glyphname') 2299 if masterGlyphName is None: 2300 # if we don't read a glyphname, use the one we have 2301 masterGlyphName = glyphName 2302 d = dict(font=fontSourceName, 2303 location=designLocation, 2304 glyphName=masterGlyphName) 2305 if glyphSources is None: 2306 glyphSources = [] 2307 glyphSources.append(d) 2308 if glyphSources is not None: 2309 glyphData['masters'] = glyphSources 2310 instanceObject.glyphs[glyphName] = glyphData 2311 2312 def readLib(self): 2313 """Read the lib element for the whole document.""" 2314 for libElement in self.root.findall(".lib"): 2315 self.documentObject.lib = plistlib.fromtree(libElement[0]) 2316 2317 2318class DesignSpaceDocument(LogMixin, AsDictMixin): 2319 """The DesignSpaceDocument object can read and write ``.designspace`` data. 2320 It imports the axes, sources, variable fonts and instances to very basic 2321 **descriptor** objects that store the data in attributes. Data is added to 2322 the document by creating such descriptor objects, filling them with data 2323 and then adding them to the document. This makes it easy to integrate this 2324 object in different contexts. 2325 2326 The **DesignSpaceDocument** object can be subclassed to work with 2327 different objects, as long as they have the same attributes. Reader and 2328 Writer objects can be subclassed as well. 2329 2330 **Note:** Python attribute names are usually camelCased, the 2331 corresponding `XML <document-xml-structure>`_ attributes are usually 2332 all lowercase. 2333 2334 .. code:: python 2335 2336 from fontTools.designspaceLib import DesignSpaceDocument 2337 doc = DesignSpaceDocument.fromfile("some/path/to/my.designspace") 2338 doc.formatVersion 2339 doc.elidedFallbackName 2340 doc.axes 2341 doc.locationLabels 2342 doc.rules 2343 doc.rulesProcessingLast 2344 doc.sources 2345 doc.variableFonts 2346 doc.instances 2347 doc.lib 2348 2349 """ 2350 2351 def __init__(self, readerClass=None, writerClass=None): 2352 self.path = None 2353 """String, optional. When the document is read from the disk, this is 2354 the full path that was given to :meth:`read` or :meth:`fromfile`. 2355 """ 2356 self.filename = None 2357 """String, optional. When the document is read from the disk, this is 2358 its original file name, i.e. the last part of its path. 2359 2360 When the document is produced by a Python script and still only exists 2361 in memory, the producing script can write here an indication of a 2362 possible "good" filename, in case one wants to save the file somewhere. 2363 """ 2364 2365 self.formatVersion: Optional[str] = None 2366 """Format version for this document, as a string. E.g. "4.0" """ 2367 2368 self.elidedFallbackName: Optional[str] = None 2369 """STAT Style Attributes Header field ``elidedFallbackNameID``. 2370 2371 See: `OTSpec STAT Style Attributes Header <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#style-attributes-header>`_ 2372 2373 .. versionadded:: 5.0 2374 """ 2375 2376 self.axes: List[Union[AxisDescriptor, DiscreteAxisDescriptor]] = [] 2377 """List of this document's axes.""" 2378 self.locationLabels: List[LocationLabelDescriptor] = [] 2379 """List of this document's STAT format 4 labels. 2380 2381 .. versionadded:: 5.0""" 2382 self.rules: List[RuleDescriptor] = [] 2383 """List of this document's rules.""" 2384 self.rulesProcessingLast: bool = False 2385 """This flag indicates whether the substitution rules should be applied 2386 before or after other glyph substitution features. 2387 2388 - False: before 2389 - True: after. 2390 2391 Default is False. For new projects, you probably want True. See 2392 the following issues for more information: 2393 `fontTools#1371 <https://github.com/fonttools/fonttools/issues/1371#issuecomment-590214572>`__ 2394 `fontTools#2050 <https://github.com/fonttools/fonttools/issues/2050#issuecomment-678691020>`__ 2395 2396 If you want to use a different feature altogether, e.g. ``calt``, 2397 use the lib key ``com.github.fonttools.varLib.featureVarsFeatureTag`` 2398 2399 .. code:: xml 2400 2401 <lib> 2402 <dict> 2403 <key>com.github.fonttools.varLib.featureVarsFeatureTag</key> 2404 <string>calt</string> 2405 </dict> 2406 </lib> 2407 """ 2408 self.sources: List[SourceDescriptor] = [] 2409 """List of this document's sources.""" 2410 self.variableFonts: List[VariableFontDescriptor] = [] 2411 """List of this document's variable fonts. 2412 2413 .. versionadded:: 5.0""" 2414 self.instances: List[InstanceDescriptor] = [] 2415 """List of this document's instances.""" 2416 self.lib: Dict = {} 2417 """User defined, custom data associated with the whole document. 2418 2419 Use reverse-DNS notation to identify your own data. 2420 Respect the data stored by others. 2421 """ 2422 2423 self.default: Optional[str] = None 2424 """Name of the default master. 2425 2426 This attribute is updated by the :meth:`findDefault` 2427 """ 2428 2429 if readerClass is not None: 2430 self.readerClass = readerClass 2431 else: 2432 self.readerClass = BaseDocReader 2433 if writerClass is not None: 2434 self.writerClass = writerClass 2435 else: 2436 self.writerClass = BaseDocWriter 2437 2438 @classmethod 2439 def fromfile(cls, path, readerClass=None, writerClass=None): 2440 """Read a designspace file from ``path`` and return a new instance of 2441 :class:. 2442 """ 2443 self = cls(readerClass=readerClass, writerClass=writerClass) 2444 self.read(path) 2445 return self 2446 2447 @classmethod 2448 def fromstring(cls, string, readerClass=None, writerClass=None): 2449 self = cls(readerClass=readerClass, writerClass=writerClass) 2450 reader = self.readerClass.fromstring(string, self) 2451 reader.read() 2452 if self.sources: 2453 self.findDefault() 2454 return self 2455 2456 def tostring(self, encoding=None): 2457 """Returns the designspace as a string. Default encoding ``utf-8``.""" 2458 if encoding is str or ( 2459 encoding is not None and encoding.lower() == "unicode" 2460 ): 2461 f = StringIO() 2462 xml_declaration = False 2463 elif encoding is None or encoding == "utf-8": 2464 f = BytesIO() 2465 encoding = "UTF-8" 2466 xml_declaration = True 2467 else: 2468 raise ValueError("unsupported encoding: '%s'" % encoding) 2469 writer = self.writerClass(f, self) 2470 writer.write(encoding=encoding, xml_declaration=xml_declaration) 2471 return f.getvalue() 2472 2473 def read(self, path): 2474 """Read a designspace file from ``path`` and populates the fields of 2475 ``self`` with the data. 2476 """ 2477 if hasattr(path, "__fspath__"): # support os.PathLike objects 2478 path = path.__fspath__() 2479 self.path = path 2480 self.filename = os.path.basename(path) 2481 reader = self.readerClass(path, self) 2482 reader.read() 2483 if self.sources: 2484 self.findDefault() 2485 2486 def write(self, path): 2487 """Write this designspace to ``path``.""" 2488 if hasattr(path, "__fspath__"): # support os.PathLike objects 2489 path = path.__fspath__() 2490 self.path = path 2491 self.filename = os.path.basename(path) 2492 self.updatePaths() 2493 writer = self.writerClass(path, self) 2494 writer.write() 2495 2496 def _posixRelativePath(self, otherPath): 2497 relative = os.path.relpath(otherPath, os.path.dirname(self.path)) 2498 return posix(relative) 2499 2500 def updatePaths(self): 2501 """ 2502 Right before we save we need to identify and respond to the following situations: 2503 In each descriptor, we have to do the right thing for the filename attribute. 2504 2505 :: 2506 2507 case 1. 2508 descriptor.filename == None 2509 descriptor.path == None 2510 2511 -- action: 2512 write as is, descriptors will not have a filename attr. 2513 useless, but no reason to interfere. 2514 2515 2516 case 2. 2517 descriptor.filename == "../something" 2518 descriptor.path == None 2519 2520 -- action: 2521 write as is. The filename attr should not be touched. 2522 2523 2524 case 3. 2525 descriptor.filename == None 2526 descriptor.path == "~/absolute/path/there" 2527 2528 -- action: 2529 calculate the relative path for filename. 2530 We're not overwriting some other value for filename, it should be fine 2531 2532 2533 case 4. 2534 descriptor.filename == '../somewhere' 2535 descriptor.path == "~/absolute/path/there" 2536 2537 -- action: 2538 there is a conflict between the given filename, and the path. 2539 So we know where the file is relative to the document. 2540 Can't guess why they're different, we just choose for path to be correct and update filename. 2541 """ 2542 assert self.path is not None 2543 for descriptor in self.sources + self.instances: 2544 if descriptor.path is not None: 2545 # case 3 and 4: filename gets updated and relativized 2546 descriptor.filename = self._posixRelativePath(descriptor.path) 2547 2548 def addSource(self, sourceDescriptor: SourceDescriptor): 2549 """Add the given ``sourceDescriptor`` to ``doc.sources``.""" 2550 self.sources.append(sourceDescriptor) 2551 2552 def addSourceDescriptor(self, **kwargs): 2553 """Instantiate a new :class:`SourceDescriptor` using the given 2554 ``kwargs`` and add it to ``doc.sources``. 2555 """ 2556 source = self.writerClass.sourceDescriptorClass(**kwargs) 2557 self.addSource(source) 2558 return source 2559 2560 def addInstance(self, instanceDescriptor: InstanceDescriptor): 2561 """Add the given ``instanceDescriptor`` to :attr:`instances`.""" 2562 self.instances.append(instanceDescriptor) 2563 2564 def addInstanceDescriptor(self, **kwargs): 2565 """Instantiate a new :class:`InstanceDescriptor` using the given 2566 ``kwargs`` and add it to :attr:`instances`. 2567 """ 2568 instance = self.writerClass.instanceDescriptorClass(**kwargs) 2569 self.addInstance(instance) 2570 return instance 2571 2572 def addAxis(self, axisDescriptor: Union[AxisDescriptor, DiscreteAxisDescriptor]): 2573 """Add the given ``axisDescriptor`` to :attr:`axes`.""" 2574 self.axes.append(axisDescriptor) 2575 2576 def addAxisDescriptor(self, **kwargs): 2577 """Instantiate a new :class:`AxisDescriptor` using the given 2578 ``kwargs`` and add it to :attr:`axes`. 2579 2580 The axis will be and instance of :class:`DiscreteAxisDescriptor` if 2581 the ``kwargs`` provide a ``value``, or a :class:`AxisDescriptor` otherwise. 2582 """ 2583 if "values" in kwargs: 2584 axis = self.writerClass.discreteAxisDescriptorClass(**kwargs) 2585 else: 2586 axis = self.writerClass.axisDescriptorClass(**kwargs) 2587 self.addAxis(axis) 2588 return axis 2589 2590 def addRule(self, ruleDescriptor: RuleDescriptor): 2591 """Add the given ``ruleDescriptor`` to :attr:`rules`.""" 2592 self.rules.append(ruleDescriptor) 2593 2594 def addRuleDescriptor(self, **kwargs): 2595 """Instantiate a new :class:`RuleDescriptor` using the given 2596 ``kwargs`` and add it to :attr:`rules`. 2597 """ 2598 rule = self.writerClass.ruleDescriptorClass(**kwargs) 2599 self.addRule(rule) 2600 return rule 2601 2602 def addVariableFont(self, variableFontDescriptor: VariableFontDescriptor): 2603 """Add the given ``variableFontDescriptor`` to :attr:`variableFonts`. 2604 2605 .. versionadded:: 5.0 2606 """ 2607 self.variableFonts.append(variableFontDescriptor) 2608 2609 def addVariableFontDescriptor(self, **kwargs): 2610 """Instantiate a new :class:`VariableFontDescriptor` using the given 2611 ``kwargs`` and add it to :attr:`variableFonts`. 2612 2613 .. versionadded:: 5.0 2614 """ 2615 variableFont = self.writerClass.variableFontDescriptorClass(**kwargs) 2616 self.addVariableFont(variableFont) 2617 return variableFont 2618 2619 def addLocationLabel(self, locationLabelDescriptor: LocationLabelDescriptor): 2620 """Add the given ``locationLabelDescriptor`` to :attr:`locationLabels`. 2621 2622 .. versionadded:: 5.0 2623 """ 2624 self.locationLabels.append(locationLabelDescriptor) 2625 2626 def addLocationLabelDescriptor(self, **kwargs): 2627 """Instantiate a new :class:`LocationLabelDescriptor` using the given 2628 ``kwargs`` and add it to :attr:`locationLabels`. 2629 2630 .. versionadded:: 5.0 2631 """ 2632 locationLabel = self.writerClass.locationLabelDescriptorClass(**kwargs) 2633 self.addLocationLabel(locationLabel) 2634 return locationLabel 2635 2636 def newDefaultLocation(self): 2637 """Return a dict with the default location in design space coordinates.""" 2638 # Without OrderedDict, output XML would be non-deterministic. 2639 # https://github.com/LettError/designSpaceDocument/issues/10 2640 loc = collections.OrderedDict() 2641 for axisDescriptor in self.axes: 2642 loc[axisDescriptor.name] = axisDescriptor.map_forward( 2643 axisDescriptor.default 2644 ) 2645 return loc 2646 2647 def labelForUserLocation(self, userLocation: SimpleLocationDict) -> Optional[LocationLabelDescriptor]: 2648 """Return the :class:`LocationLabel` that matches the given 2649 ``userLocation``, or ``None`` if no such label exists. 2650 2651 .. versionadded:: 5.0 2652 """ 2653 return next( 2654 (label for label in self.locationLabels if label.userLocation == userLocation), None 2655 ) 2656 2657 def updateFilenameFromPath(self, masters=True, instances=True, force=False): 2658 """Set a descriptor filename attr from the path and this document path. 2659 2660 If the filename attribute is not None: skip it. 2661 """ 2662 if masters: 2663 for descriptor in self.sources: 2664 if descriptor.filename is not None and not force: 2665 continue 2666 if self.path is not None: 2667 descriptor.filename = self._posixRelativePath(descriptor.path) 2668 if instances: 2669 for descriptor in self.instances: 2670 if descriptor.filename is not None and not force: 2671 continue 2672 if self.path is not None: 2673 descriptor.filename = self._posixRelativePath(descriptor.path) 2674 2675 def newAxisDescriptor(self): 2676 """Ask the writer class to make us a new axisDescriptor.""" 2677 return self.writerClass.getAxisDecriptor() 2678 2679 def newSourceDescriptor(self): 2680 """Ask the writer class to make us a new sourceDescriptor.""" 2681 return self.writerClass.getSourceDescriptor() 2682 2683 def newInstanceDescriptor(self): 2684 """Ask the writer class to make us a new instanceDescriptor.""" 2685 return self.writerClass.getInstanceDescriptor() 2686 2687 def getAxisOrder(self): 2688 """Return a list of axis names, in the same order as defined in the document.""" 2689 names = [] 2690 for axisDescriptor in self.axes: 2691 names.append(axisDescriptor.name) 2692 return names 2693 2694 def getAxis(self, name): 2695 """Return the axis with the given ``name``, or ``None`` if no such axis exists.""" 2696 for axisDescriptor in self.axes: 2697 if axisDescriptor.name == name: 2698 return axisDescriptor 2699 return None 2700 2701 def getLocationLabel(self, name: str) -> Optional[LocationLabelDescriptor]: 2702 """Return the top-level location label with the given ``name``, or 2703 ``None`` if no such label exists. 2704 2705 .. versionadded:: 5.0 2706 """ 2707 for label in self.locationLabels: 2708 if label.name == name: 2709 return label 2710 return None 2711 2712 def map_forward(self, userLocation: SimpleLocationDict) -> SimpleLocationDict: 2713 """Map a user location to a design location. 2714 2715 Assume that missing coordinates are at the default location for that axis. 2716 2717 Note: the output won't be anisotropic, only the xvalue is set. 2718 2719 .. versionadded:: 5.0 2720 """ 2721 return { 2722 axis.name: axis.map_forward(userLocation.get(axis.name, axis.default)) 2723 for axis in self.axes 2724 } 2725 2726 def map_backward(self, designLocation: AnisotropicLocationDict) -> SimpleLocationDict: 2727 """Map a design location to a user location. 2728 2729 Assume that missing coordinates are at the default location for that axis. 2730 2731 When the input has anisotropic locations, only the xvalue is used. 2732 2733 .. versionadded:: 5.0 2734 """ 2735 return { 2736 axis.name: ( 2737 axis.map_backward(designLocation[axis.name]) 2738 if axis.name in designLocation 2739 else axis.default 2740 ) 2741 for axis in self.axes 2742 } 2743 2744 def findDefault(self): 2745 """Set and return SourceDescriptor at the default location or None. 2746 2747 The default location is the set of all `default` values in user space 2748 of all axes. 2749 2750 This function updates the document's :attr:`default` value. 2751 2752 .. versionchanged:: 5.0 2753 Allow the default source to not specify some of the axis values, and 2754 they are assumed to be the default. 2755 See :meth:`SourceDescriptor.getFullDesignLocation()` 2756 """ 2757 self.default = None 2758 2759 # Convert the default location from user space to design space before comparing 2760 # it against the SourceDescriptor locations (always in design space). 2761 defaultDesignLocation = self.newDefaultLocation() 2762 2763 for sourceDescriptor in self.sources: 2764 if sourceDescriptor.getFullDesignLocation(self) == defaultDesignLocation: 2765 self.default = sourceDescriptor 2766 return sourceDescriptor 2767 2768 return None 2769 2770 def normalizeLocation(self, location): 2771 """Return a dict with normalized axis values.""" 2772 from fontTools.varLib.models import normalizeValue 2773 2774 new = {} 2775 for axis in self.axes: 2776 if axis.name not in location: 2777 # skipping this dimension it seems 2778 continue 2779 value = location[axis.name] 2780 # 'anisotropic' location, take first coord only 2781 if isinstance(value, tuple): 2782 value = value[0] 2783 triple = [ 2784 axis.map_forward(v) for v in (axis.minimum, axis.default, axis.maximum) 2785 ] 2786 new[axis.name] = normalizeValue(value, triple) 2787 return new 2788 2789 def normalize(self): 2790 """ 2791 Normalise the geometry of this designspace: 2792 2793 - scale all the locations of all masters and instances to the -1 - 0 - 1 value. 2794 - we need the axis data to do the scaling, so we do those last. 2795 """ 2796 # masters 2797 for item in self.sources: 2798 item.location = self.normalizeLocation(item.location) 2799 # instances 2800 for item in self.instances: 2801 # glyph masters for this instance 2802 for _, glyphData in item.glyphs.items(): 2803 glyphData['instanceLocation'] = self.normalizeLocation(glyphData['instanceLocation']) 2804 for glyphMaster in glyphData['masters']: 2805 glyphMaster['location'] = self.normalizeLocation(glyphMaster['location']) 2806 item.location = self.normalizeLocation(item.location) 2807 # the axes 2808 for axis in self.axes: 2809 # scale the map first 2810 newMap = [] 2811 for inputValue, outputValue in axis.map: 2812 newOutputValue = self.normalizeLocation({axis.name: outputValue}).get(axis.name) 2813 newMap.append((inputValue, newOutputValue)) 2814 if newMap: 2815 axis.map = newMap 2816 # finally the axis values 2817 minimum = self.normalizeLocation({axis.name: axis.minimum}).get(axis.name) 2818 maximum = self.normalizeLocation({axis.name: axis.maximum}).get(axis.name) 2819 default = self.normalizeLocation({axis.name: axis.default}).get(axis.name) 2820 # and set them in the axis.minimum 2821 axis.minimum = minimum 2822 axis.maximum = maximum 2823 axis.default = default 2824 # now the rules 2825 for rule in self.rules: 2826 newConditionSets = [] 2827 for conditions in rule.conditionSets: 2828 newConditions = [] 2829 for cond in conditions: 2830 if cond.get('minimum') is not None: 2831 minimum = self.normalizeLocation({cond['name']: cond['minimum']}).get(cond['name']) 2832 else: 2833 minimum = None 2834 if cond.get('maximum') is not None: 2835 maximum = self.normalizeLocation({cond['name']: cond['maximum']}).get(cond['name']) 2836 else: 2837 maximum = None 2838 newConditions.append(dict(name=cond['name'], minimum=minimum, maximum=maximum)) 2839 newConditionSets.append(newConditions) 2840 rule.conditionSets = newConditionSets 2841 2842 def loadSourceFonts(self, opener, **kwargs): 2843 """Ensure SourceDescriptor.font attributes are loaded, and return list of fonts. 2844 2845 Takes a callable which initializes a new font object (e.g. TTFont, or 2846 defcon.Font, etc.) from the SourceDescriptor.path, and sets the 2847 SourceDescriptor.font attribute. 2848 If the font attribute is already not None, it is not loaded again. 2849 Fonts with the same path are only loaded once and shared among SourceDescriptors. 2850 2851 For example, to load UFO sources using defcon: 2852 2853 designspace = DesignSpaceDocument.fromfile("path/to/my.designspace") 2854 designspace.loadSourceFonts(defcon.Font) 2855 2856 Or to load masters as FontTools binary fonts, including extra options: 2857 2858 designspace.loadSourceFonts(ttLib.TTFont, recalcBBoxes=False) 2859 2860 Args: 2861 opener (Callable): takes one required positional argument, the source.path, 2862 and an optional list of keyword arguments, and returns a new font object 2863 loaded from the path. 2864 **kwargs: extra options passed on to the opener function. 2865 2866 Returns: 2867 List of font objects in the order they appear in the sources list. 2868 """ 2869 # we load fonts with the same source.path only once 2870 loaded = {} 2871 fonts = [] 2872 for source in self.sources: 2873 if source.font is not None: # font already loaded 2874 fonts.append(source.font) 2875 continue 2876 if source.path in loaded: 2877 source.font = loaded[source.path] 2878 else: 2879 if source.path is None: 2880 raise DesignSpaceDocumentError( 2881 "Designspace source '%s' has no 'path' attribute" 2882 % (source.name or "<Unknown>") 2883 ) 2884 source.font = opener(source.path, **kwargs) 2885 loaded[source.path] = source.font 2886 fonts.append(source.font) 2887 return fonts 2888 2889 @property 2890 def formatTuple(self): 2891 """Return the formatVersion as a tuple of (major, minor). 2892 2893 .. versionadded:: 5.0 2894 """ 2895 if self.formatVersion is None: 2896 return (5, 0) 2897 numbers = (int(i) for i in self.formatVersion.split(".")) 2898 major = next(numbers) 2899 minor = next(numbers, 0) 2900 return (major, minor) 2901 2902 def getVariableFonts(self) -> List[VariableFontDescriptor]: 2903 """Return all variable fonts defined in this document, or implicit 2904 variable fonts that can be built from the document's continuous axes. 2905 2906 In the case of Designspace documents before version 5, the whole 2907 document was implicitly describing a variable font that covers the 2908 whole space. 2909 2910 In version 5 and above documents, there can be as many variable fonts 2911 as there are locations on discrete axes. 2912 2913 .. seealso:: :func:`splitInterpolable` 2914 2915 .. versionadded:: 5.0 2916 """ 2917 if self.variableFonts: 2918 return self.variableFonts 2919 2920 variableFonts = [] 2921 discreteAxes = [] 2922 rangeAxisSubsets: List[Union[RangeAxisSubsetDescriptor, ValueAxisSubsetDescriptor]] = [] 2923 for axis in self.axes: 2924 if hasattr(axis, "values"): 2925 # Mypy doesn't support narrowing union types via hasattr() 2926 # TODO(Python 3.10): use TypeGuard 2927 # https://mypy.readthedocs.io/en/stable/type_narrowing.html 2928 axis = cast(DiscreteAxisDescriptor, axis) 2929 discreteAxes.append(axis) # type: ignore 2930 else: 2931 rangeAxisSubsets.append(RangeAxisSubsetDescriptor(name=axis.name)) 2932 valueCombinations = itertools.product(*[axis.values for axis in discreteAxes]) 2933 for values in valueCombinations: 2934 basename = None 2935 if self.filename is not None: 2936 basename = os.path.splitext(self.filename)[0] + "-VF" 2937 if self.path is not None: 2938 basename = os.path.splitext(os.path.basename(self.path))[0] + "-VF" 2939 if basename is None: 2940 basename = "VF" 2941 axisNames = "".join([f"-{axis.tag}{value}" for axis, value in zip(discreteAxes, values)]) 2942 variableFonts.append(VariableFontDescriptor( 2943 name=f"{basename}{axisNames}", 2944 axisSubsets=rangeAxisSubsets + [ 2945 ValueAxisSubsetDescriptor(name=axis.name, userValue=value) 2946 for axis, value in zip(discreteAxes, values) 2947 ] 2948 )) 2949 return variableFonts 2950 2951 def deepcopyExceptFonts(self): 2952 """Allow deep-copying a DesignSpace document without deep-copying 2953 attached UFO fonts or TTFont objects. The :attr:`font` attribute 2954 is shared by reference between the original and the copy. 2955 2956 .. versionadded:: 5.0 2957 """ 2958 fonts = [source.font for source in self.sources] 2959 try: 2960 for source in self.sources: 2961 source.font = None 2962 res = copy.deepcopy(self) 2963 for source, font in zip(res.sources, fonts): 2964 source.font = font 2965 return res 2966 finally: 2967 for source, font in zip(self.sources, fonts): 2968 source.font = font 2969 2970