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