1# -*- coding: utf-8 -*- 2 3from __future__ import print_function, division, absolute_import 4from fontTools.misc.py23 import * 5from fontTools.misc.loggingTools import LogMixin 6import collections 7import os 8import posixpath 9from fontTools.misc import etree as ET 10from fontTools.misc import plistlib 11 12""" 13 designSpaceDocument 14 15 - read and write designspace files 16""" 17 18__all__ = [ 19 'DesignSpaceDocumentError', 'DesignSpaceDocument', 'SourceDescriptor', 20 'InstanceDescriptor', 'AxisDescriptor', 'RuleDescriptor', 'BaseDocReader', 21 'BaseDocWriter' 22] 23 24# ElementTree allows to find namespace-prefixed elements, but not attributes 25# so we have to do it ourselves for 'xml:lang' 26XML_NS = "{http://www.w3.org/XML/1998/namespace}" 27XML_LANG = XML_NS + "lang" 28 29 30def posix(path): 31 """Normalize paths using forward slash to work also on Windows.""" 32 new_path = posixpath.join(*path.split(os.path.sep)) 33 if path.startswith('/'): 34 # The above transformation loses absolute paths 35 new_path = '/' + new_path 36 return new_path 37 38 39def posixpath_property(private_name): 40 def getter(self): 41 # Normal getter 42 return getattr(self, private_name) 43 44 def setter(self, value): 45 # The setter rewrites paths using forward slashes 46 if value is not None: 47 value = posix(value) 48 setattr(self, private_name, value) 49 50 return property(getter, setter) 51 52 53class DesignSpaceDocumentError(Exception): 54 def __init__(self, msg, obj=None): 55 self.msg = msg 56 self.obj = obj 57 58 def __str__(self): 59 return str(self.msg) + ( 60 ": %r" % self.obj if self.obj is not None else "") 61 62 63class AsDictMixin(object): 64 65 def asdict(self): 66 d = {} 67 for attr, value in self.__dict__.items(): 68 if attr.startswith("_"): 69 continue 70 if hasattr(value, "asdict"): 71 value = value.asdict() 72 elif isinstance(value, list): 73 value = [ 74 v.asdict() if hasattr(v, "asdict") else v for v in value 75 ] 76 d[attr] = value 77 return d 78 79 80class SimpleDescriptor(AsDictMixin): 81 """ Containers for a bunch of attributes""" 82 83 # XXX this is ugly. The 'print' is inappropriate here, and instead of 84 # assert, it should simply return True/False 85 def compare(self, other): 86 # test if this object contains the same data as the other 87 for attr in self._attrs: 88 try: 89 assert(getattr(self, attr) == getattr(other, attr)) 90 except AssertionError: 91 print("failed attribute", attr, getattr(self, attr), "!=", getattr(other, attr)) 92 93 94class SourceDescriptor(SimpleDescriptor): 95 """Simple container for data related to the source""" 96 flavor = "source" 97 _attrs = ['filename', 'path', 'name', 'layerName', 98 'location', 'copyLib', 99 'copyGroups', 'copyFeatures', 100 'muteKerning', 'muteInfo', 101 'mutedGlyphNames', 102 'familyName', 'styleName'] 103 104 def __init__(self): 105 self.filename = None 106 """The original path as found in the document.""" 107 108 self.path = None 109 """The absolute path, calculated from filename.""" 110 111 self.font = None 112 """Any Python object. Optional. Points to a representation of this 113 source font that is loaded in memory, as a Python object (e.g. a 114 ``defcon.Font`` or a ``fontTools.ttFont.TTFont``). 115 116 The default document reader will not fill-in this attribute, and the 117 default writer will not use this attribute. It is up to the user of 118 ``designspaceLib`` to either load the resource identified by 119 ``filename`` and store it in this field, or write the contents of 120 this field to the disk and make ```filename`` point to that. 121 """ 122 123 self.name = None 124 self.location = None 125 self.layerName = None 126 self.copyLib = False 127 self.copyInfo = False 128 self.copyGroups = False 129 self.copyFeatures = False 130 self.muteKerning = False 131 self.muteInfo = False 132 self.mutedGlyphNames = [] 133 self.familyName = None 134 self.styleName = None 135 136 path = posixpath_property("_path") 137 filename = posixpath_property("_filename") 138 139 140class RuleDescriptor(SimpleDescriptor): 141 """<!-- optional: list of substitution rules --> 142 <rules> 143 <rule name="vertical.bars"> 144 <conditionset> 145 <condition minimum="250.000000" maximum="750.000000" name="weight"/> 146 <condition minimum="100" name="width"/> 147 <condition minimum="10" maximum="40" name="optical"/> 148 </conditionset> 149 <sub name="cent" with="cent.alt"/> 150 <sub name="dollar" with="dollar.alt"/> 151 </rule> 152 </rules> 153 """ 154 _attrs = ['name', 'conditionSets', 'subs'] # what do we need here 155 156 def __init__(self): 157 self.name = None 158 self.conditionSets = [] # list of list of dict(name='aaaa', minimum=0, maximum=1000) 159 self.subs = [] # list of substitutions stored as tuples of glyphnames ("a", "a.alt") 160 161 162def evaluateRule(rule, location): 163 """ Return True if any of the rule's conditionsets matches the given location.""" 164 return any(evaluateConditions(c, location) for c in rule.conditionSets) 165 166 167def evaluateConditions(conditions, location): 168 """ Return True if all the conditions matches the given location. 169 If a condition has no minimum, check for < maximum. 170 If a condition has no maximum, check for > minimum. 171 """ 172 for cd in conditions: 173 value = location[cd['name']] 174 if cd.get('minimum') is None: 175 if value > cd['maximum']: 176 return False 177 elif cd.get('maximum') is None: 178 if cd['minimum'] > value: 179 return False 180 elif not cd['minimum'] <= value <= cd['maximum']: 181 return False 182 return True 183 184 185def processRules(rules, location, glyphNames): 186 """ Apply these rules at this location to these glyphnames.minimum 187 - rule order matters 188 """ 189 newNames = [] 190 for rule in rules: 191 if evaluateRule(rule, location): 192 for name in glyphNames: 193 swap = False 194 for a, b in rule.subs: 195 if name == a: 196 swap = True 197 break 198 if swap: 199 newNames.append(b) 200 else: 201 newNames.append(name) 202 glyphNames = newNames 203 newNames = [] 204 return glyphNames 205 206 207class InstanceDescriptor(SimpleDescriptor): 208 """Simple container for data related to the instance""" 209 flavor = "instance" 210 _defaultLanguageCode = "en" 211 _attrs = ['path', 212 'name', 213 'location', 214 'familyName', 215 'styleName', 216 'postScriptFontName', 217 'styleMapFamilyName', 218 'styleMapStyleName', 219 'kerning', 220 'info', 221 'lib'] 222 223 def __init__(self): 224 self.filename = None # the original path as found in the document 225 self.path = None # the absolute path, calculated from filename 226 self.name = None 227 self.location = None 228 self.familyName = None 229 self.styleName = None 230 self.postScriptFontName = None 231 self.styleMapFamilyName = None 232 self.styleMapStyleName = None 233 self.localisedStyleName = {} 234 self.localisedFamilyName = {} 235 self.localisedStyleMapStyleName = {} 236 self.localisedStyleMapFamilyName = {} 237 self.glyphs = {} 238 self.kerning = True 239 self.info = True 240 241 self.lib = {} 242 """Custom data associated with this instance.""" 243 244 path = posixpath_property("_path") 245 filename = posixpath_property("_filename") 246 247 def setStyleName(self, styleName, languageCode="en"): 248 self.localisedStyleName[languageCode] = tounicode(styleName) 249 250 def getStyleName(self, languageCode="en"): 251 return self.localisedStyleName.get(languageCode) 252 253 def setFamilyName(self, familyName, languageCode="en"): 254 self.localisedFamilyName[languageCode] = tounicode(familyName) 255 256 def getFamilyName(self, languageCode="en"): 257 return self.localisedFamilyName.get(languageCode) 258 259 def setStyleMapStyleName(self, styleMapStyleName, languageCode="en"): 260 self.localisedStyleMapStyleName[languageCode] = tounicode(styleMapStyleName) 261 262 def getStyleMapStyleName(self, languageCode="en"): 263 return self.localisedStyleMapStyleName.get(languageCode) 264 265 def setStyleMapFamilyName(self, styleMapFamilyName, languageCode="en"): 266 self.localisedStyleMapFamilyName[languageCode] = tounicode(styleMapFamilyName) 267 268 def getStyleMapFamilyName(self, languageCode="en"): 269 return self.localisedStyleMapFamilyName.get(languageCode) 270 271 272def tagForAxisName(name): 273 # try to find or make a tag name for this axis name 274 names = { 275 'weight': ('wght', dict(en = 'Weight')), 276 'width': ('wdth', dict(en = 'Width')), 277 'optical': ('opsz', dict(en = 'Optical Size')), 278 'slant': ('slnt', dict(en = 'Slant')), 279 'italic': ('ital', dict(en = 'Italic')), 280 } 281 if name.lower() in names: 282 return names[name.lower()] 283 if len(name) < 4: 284 tag = name + "*" * (4 - len(name)) 285 else: 286 tag = name[:4] 287 return tag, dict(en=name) 288 289 290class AxisDescriptor(SimpleDescriptor): 291 """ Simple container for the axis data 292 Add more localisations? 293 """ 294 flavor = "axis" 295 _attrs = ['tag', 'name', 'maximum', 'minimum', 'default', 'map'] 296 297 def __init__(self): 298 self.tag = None # opentype tag for this axis 299 self.name = None # name of the axis used in locations 300 self.labelNames = {} # names for UI purposes, if this is not a standard axis, 301 self.minimum = None 302 self.maximum = None 303 self.default = None 304 self.hidden = False 305 self.map = [] 306 307 def serialize(self): 308 # output to a dict, used in testing 309 return dict( 310 tag=self.tag, 311 name=self.name, 312 labelNames=self.labelNames, 313 maximum=self.maximum, 314 minimum=self.minimum, 315 default=self.default, 316 hidden=self.hidden, 317 map=self.map, 318 ) 319 320 def map_forward(self, v): 321 from fontTools.varLib.models import piecewiseLinearMap 322 323 if not self.map: 324 return v 325 return piecewiseLinearMap(v, {k: v for k, v in self.map}) 326 327 def map_backward(self, v): 328 from fontTools.varLib.models import piecewiseLinearMap 329 330 if not self.map: 331 return v 332 return piecewiseLinearMap(v, {v: k for k, v in self.map}) 333 334 335class BaseDocWriter(object): 336 _whiteSpace = " " 337 ruleDescriptorClass = RuleDescriptor 338 axisDescriptorClass = AxisDescriptor 339 sourceDescriptorClass = SourceDescriptor 340 instanceDescriptorClass = InstanceDescriptor 341 342 @classmethod 343 def getAxisDecriptor(cls): 344 return cls.axisDescriptorClass() 345 346 @classmethod 347 def getSourceDescriptor(cls): 348 return cls.sourceDescriptorClass() 349 350 @classmethod 351 def getInstanceDescriptor(cls): 352 return cls.instanceDescriptorClass() 353 354 @classmethod 355 def getRuleDescriptor(cls): 356 return cls.ruleDescriptorClass() 357 358 def __init__(self, documentPath, documentObject): 359 self.path = documentPath 360 self.documentObject = documentObject 361 self.documentVersion = "4.0" 362 self.root = ET.Element("designspace") 363 self.root.attrib['format'] = self.documentVersion 364 self._axes = [] # for use by the writer only 365 self._rules = [] # for use by the writer only 366 367 def write(self, pretty=True, encoding="UTF-8", xml_declaration=True): 368 if self.documentObject.axes: 369 self.root.append(ET.Element("axes")) 370 for axisObject in self.documentObject.axes: 371 self._addAxis(axisObject) 372 373 if self.documentObject.rules: 374 self.root.append(ET.Element("rules")) 375 for ruleObject in self.documentObject.rules: 376 self._addRule(ruleObject) 377 378 if self.documentObject.sources: 379 self.root.append(ET.Element("sources")) 380 for sourceObject in self.documentObject.sources: 381 self._addSource(sourceObject) 382 383 if self.documentObject.instances: 384 self.root.append(ET.Element("instances")) 385 for instanceObject in self.documentObject.instances: 386 self._addInstance(instanceObject) 387 388 if self.documentObject.lib: 389 self._addLib(self.documentObject.lib) 390 391 tree = ET.ElementTree(self.root) 392 tree.write( 393 self.path, 394 encoding=encoding, 395 method='xml', 396 xml_declaration=xml_declaration, 397 pretty_print=pretty, 398 ) 399 400 def _makeLocationElement(self, locationObject, name=None): 401 """ Convert Location dict to a locationElement.""" 402 locElement = ET.Element("location") 403 if name is not None: 404 locElement.attrib['name'] = name 405 validatedLocation = self.documentObject.newDefaultLocation() 406 for axisName, axisValue in locationObject.items(): 407 if axisName in validatedLocation: 408 # only accept values we know 409 validatedLocation[axisName] = axisValue 410 for dimensionName, dimensionValue in validatedLocation.items(): 411 dimElement = ET.Element('dimension') 412 dimElement.attrib['name'] = dimensionName 413 if type(dimensionValue) == tuple: 414 dimElement.attrib['xvalue'] = self.intOrFloat(dimensionValue[0]) 415 dimElement.attrib['yvalue'] = self.intOrFloat(dimensionValue[1]) 416 else: 417 dimElement.attrib['xvalue'] = self.intOrFloat(dimensionValue) 418 locElement.append(dimElement) 419 return locElement, validatedLocation 420 421 def intOrFloat(self, num): 422 if int(num) == num: 423 return "%d" % num 424 return "%f" % num 425 426 def _addRule(self, ruleObject): 427 # if none of the conditions have minimum or maximum values, do not add the rule. 428 self._rules.append(ruleObject) 429 ruleElement = ET.Element('rule') 430 if ruleObject.name is not None: 431 ruleElement.attrib['name'] = ruleObject.name 432 for conditions in ruleObject.conditionSets: 433 conditionsetElement = ET.Element('conditionset') 434 for cond in conditions: 435 if cond.get('minimum') is None and cond.get('maximum') is None: 436 # neither is defined, don't add this condition 437 continue 438 conditionElement = ET.Element('condition') 439 conditionElement.attrib['name'] = cond.get('name') 440 if cond.get('minimum') is not None: 441 conditionElement.attrib['minimum'] = self.intOrFloat(cond.get('minimum')) 442 if cond.get('maximum') is not None: 443 conditionElement.attrib['maximum'] = self.intOrFloat(cond.get('maximum')) 444 conditionsetElement.append(conditionElement) 445 if len(conditionsetElement): 446 ruleElement.append(conditionsetElement) 447 for sub in ruleObject.subs: 448 subElement = ET.Element('sub') 449 subElement.attrib['name'] = sub[0] 450 subElement.attrib['with'] = sub[1] 451 ruleElement.append(subElement) 452 if len(ruleElement): 453 self.root.findall('.rules')[0].append(ruleElement) 454 455 def _addAxis(self, axisObject): 456 self._axes.append(axisObject) 457 axisElement = ET.Element('axis') 458 axisElement.attrib['tag'] = axisObject.tag 459 axisElement.attrib['name'] = axisObject.name 460 axisElement.attrib['minimum'] = self.intOrFloat(axisObject.minimum) 461 axisElement.attrib['maximum'] = self.intOrFloat(axisObject.maximum) 462 axisElement.attrib['default'] = self.intOrFloat(axisObject.default) 463 if axisObject.hidden: 464 axisElement.attrib['hidden'] = "1" 465 for languageCode, labelName in sorted(axisObject.labelNames.items()): 466 languageElement = ET.Element('labelname') 467 languageElement.attrib[XML_LANG] = languageCode 468 languageElement.text = labelName 469 axisElement.append(languageElement) 470 if axisObject.map: 471 for inputValue, outputValue in axisObject.map: 472 mapElement = ET.Element('map') 473 mapElement.attrib['input'] = self.intOrFloat(inputValue) 474 mapElement.attrib['output'] = self.intOrFloat(outputValue) 475 axisElement.append(mapElement) 476 self.root.findall('.axes')[0].append(axisElement) 477 478 def _addInstance(self, instanceObject): 479 instanceElement = ET.Element('instance') 480 if instanceObject.name is not None: 481 instanceElement.attrib['name'] = instanceObject.name 482 if instanceObject.familyName is not None: 483 instanceElement.attrib['familyname'] = instanceObject.familyName 484 if instanceObject.styleName is not None: 485 instanceElement.attrib['stylename'] = instanceObject.styleName 486 # add localisations 487 if instanceObject.localisedStyleName: 488 languageCodes = list(instanceObject.localisedStyleName.keys()) 489 languageCodes.sort() 490 for code in languageCodes: 491 if code == "en": 492 continue # already stored in the element attribute 493 localisedStyleNameElement = ET.Element('stylename') 494 localisedStyleNameElement.attrib[XML_LANG] = code 495 localisedStyleNameElement.text = instanceObject.getStyleName(code) 496 instanceElement.append(localisedStyleNameElement) 497 if instanceObject.localisedFamilyName: 498 languageCodes = list(instanceObject.localisedFamilyName.keys()) 499 languageCodes.sort() 500 for code in languageCodes: 501 if code == "en": 502 continue # already stored in the element attribute 503 localisedFamilyNameElement = ET.Element('familyname') 504 localisedFamilyNameElement.attrib[XML_LANG] = code 505 localisedFamilyNameElement.text = instanceObject.getFamilyName(code) 506 instanceElement.append(localisedFamilyNameElement) 507 if instanceObject.localisedStyleMapStyleName: 508 languageCodes = list(instanceObject.localisedStyleMapStyleName.keys()) 509 languageCodes.sort() 510 for code in languageCodes: 511 if code == "en": 512 continue 513 localisedStyleMapStyleNameElement = ET.Element('stylemapstylename') 514 localisedStyleMapStyleNameElement.attrib[XML_LANG] = code 515 localisedStyleMapStyleNameElement.text = instanceObject.getStyleMapStyleName(code) 516 instanceElement.append(localisedStyleMapStyleNameElement) 517 if instanceObject.localisedStyleMapFamilyName: 518 languageCodes = list(instanceObject.localisedStyleMapFamilyName.keys()) 519 languageCodes.sort() 520 for code in languageCodes: 521 if code == "en": 522 continue 523 localisedStyleMapFamilyNameElement = ET.Element('stylemapfamilyname') 524 localisedStyleMapFamilyNameElement.attrib[XML_LANG] = code 525 localisedStyleMapFamilyNameElement.text = instanceObject.getStyleMapFamilyName(code) 526 instanceElement.append(localisedStyleMapFamilyNameElement) 527 528 if instanceObject.location is not None: 529 locationElement, instanceObject.location = self._makeLocationElement(instanceObject.location) 530 instanceElement.append(locationElement) 531 if instanceObject.filename is not None: 532 instanceElement.attrib['filename'] = instanceObject.filename 533 if instanceObject.postScriptFontName is not None: 534 instanceElement.attrib['postscriptfontname'] = instanceObject.postScriptFontName 535 if instanceObject.styleMapFamilyName is not None: 536 instanceElement.attrib['stylemapfamilyname'] = instanceObject.styleMapFamilyName 537 if instanceObject.styleMapStyleName is not None: 538 instanceElement.attrib['stylemapstylename'] = instanceObject.styleMapStyleName 539 if instanceObject.glyphs: 540 if instanceElement.findall('.glyphs') == []: 541 glyphsElement = ET.Element('glyphs') 542 instanceElement.append(glyphsElement) 543 glyphsElement = instanceElement.findall('.glyphs')[0] 544 for glyphName, data in sorted(instanceObject.glyphs.items()): 545 glyphElement = self._writeGlyphElement(instanceElement, instanceObject, glyphName, data) 546 glyphsElement.append(glyphElement) 547 if instanceObject.kerning: 548 kerningElement = ET.Element('kerning') 549 instanceElement.append(kerningElement) 550 if instanceObject.info: 551 infoElement = ET.Element('info') 552 instanceElement.append(infoElement) 553 if instanceObject.lib: 554 libElement = ET.Element('lib') 555 libElement.append(plistlib.totree(instanceObject.lib, indent_level=4)) 556 instanceElement.append(libElement) 557 self.root.findall('.instances')[0].append(instanceElement) 558 559 def _addSource(self, sourceObject): 560 sourceElement = ET.Element("source") 561 if sourceObject.filename is not None: 562 sourceElement.attrib['filename'] = sourceObject.filename 563 if sourceObject.name is not None: 564 if sourceObject.name.find("temp_master") != 0: 565 # do not save temporary source names 566 sourceElement.attrib['name'] = sourceObject.name 567 if sourceObject.familyName is not None: 568 sourceElement.attrib['familyname'] = sourceObject.familyName 569 if sourceObject.styleName is not None: 570 sourceElement.attrib['stylename'] = sourceObject.styleName 571 if sourceObject.layerName is not None: 572 sourceElement.attrib['layer'] = sourceObject.layerName 573 if sourceObject.copyLib: 574 libElement = ET.Element('lib') 575 libElement.attrib['copy'] = "1" 576 sourceElement.append(libElement) 577 if sourceObject.copyGroups: 578 groupsElement = ET.Element('groups') 579 groupsElement.attrib['copy'] = "1" 580 sourceElement.append(groupsElement) 581 if sourceObject.copyFeatures: 582 featuresElement = ET.Element('features') 583 featuresElement.attrib['copy'] = "1" 584 sourceElement.append(featuresElement) 585 if sourceObject.copyInfo or sourceObject.muteInfo: 586 infoElement = ET.Element('info') 587 if sourceObject.copyInfo: 588 infoElement.attrib['copy'] = "1" 589 if sourceObject.muteInfo: 590 infoElement.attrib['mute'] = "1" 591 sourceElement.append(infoElement) 592 if sourceObject.muteKerning: 593 kerningElement = ET.Element("kerning") 594 kerningElement.attrib["mute"] = '1' 595 sourceElement.append(kerningElement) 596 if sourceObject.mutedGlyphNames: 597 for name in sourceObject.mutedGlyphNames: 598 glyphElement = ET.Element("glyph") 599 glyphElement.attrib["name"] = name 600 glyphElement.attrib["mute"] = '1' 601 sourceElement.append(glyphElement) 602 locationElement, sourceObject.location = self._makeLocationElement(sourceObject.location) 603 sourceElement.append(locationElement) 604 self.root.findall('.sources')[0].append(sourceElement) 605 606 def _addLib(self, dict): 607 libElement = ET.Element('lib') 608 libElement.append(plistlib.totree(dict, indent_level=2)) 609 self.root.append(libElement) 610 611 def _writeGlyphElement(self, instanceElement, instanceObject, glyphName, data): 612 glyphElement = ET.Element('glyph') 613 if data.get('mute'): 614 glyphElement.attrib['mute'] = "1" 615 if data.get('unicodes') is not None: 616 glyphElement.attrib['unicode'] = " ".join([hex(u) for u in data.get('unicodes')]) 617 if data.get('instanceLocation') is not None: 618 locationElement, data['instanceLocation'] = self._makeLocationElement(data.get('instanceLocation')) 619 glyphElement.append(locationElement) 620 if glyphName is not None: 621 glyphElement.attrib['name'] = glyphName 622 if data.get('note') is not None: 623 noteElement = ET.Element('note') 624 noteElement.text = data.get('note') 625 glyphElement.append(noteElement) 626 if data.get('masters') is not None: 627 mastersElement = ET.Element("masters") 628 for m in data.get('masters'): 629 masterElement = ET.Element("master") 630 if m.get('glyphName') is not None: 631 masterElement.attrib['glyphname'] = m.get('glyphName') 632 if m.get('font') is not None: 633 masterElement.attrib['source'] = m.get('font') 634 if m.get('location') is not None: 635 locationElement, m['location'] = self._makeLocationElement(m.get('location')) 636 masterElement.append(locationElement) 637 mastersElement.append(masterElement) 638 glyphElement.append(mastersElement) 639 return glyphElement 640 641 642class BaseDocReader(LogMixin): 643 ruleDescriptorClass = RuleDescriptor 644 axisDescriptorClass = AxisDescriptor 645 sourceDescriptorClass = SourceDescriptor 646 instanceDescriptorClass = InstanceDescriptor 647 648 def __init__(self, documentPath, documentObject): 649 self.path = documentPath 650 self.documentObject = documentObject 651 tree = ET.parse(self.path) 652 self.root = tree.getroot() 653 self.documentObject.formatVersion = self.root.attrib.get("format", "3.0") 654 self._axes = [] 655 self.rules = [] 656 self.sources = [] 657 self.instances = [] 658 self.axisDefaults = {} 659 self._strictAxisNames = True 660 661 @classmethod 662 def fromstring(cls, string, documentObject): 663 f = BytesIO(tobytes(string, encoding="utf-8")) 664 self = cls(f, documentObject) 665 self.path = None 666 return self 667 668 def read(self): 669 self.readAxes() 670 self.readRules() 671 self.readSources() 672 self.readInstances() 673 self.readLib() 674 675 def readRules(self): 676 # we also need to read any conditions that are outside of a condition set. 677 rules = [] 678 for ruleElement in self.root.findall(".rules/rule"): 679 ruleObject = self.ruleDescriptorClass() 680 ruleName = ruleObject.name = ruleElement.attrib.get("name") 681 # read any stray conditions outside a condition set 682 externalConditions = self._readConditionElements( 683 ruleElement, 684 ruleName, 685 ) 686 if externalConditions: 687 ruleObject.conditionSets.append(externalConditions) 688 self.log.info( 689 "Found stray rule conditions outside a conditionset. " 690 "Wrapped them in a new conditionset." 691 ) 692 # read the conditionsets 693 for conditionSetElement in ruleElement.findall('.conditionset'): 694 conditionSet = self._readConditionElements( 695 conditionSetElement, 696 ruleName, 697 ) 698 if conditionSet is not None: 699 ruleObject.conditionSets.append(conditionSet) 700 for subElement in ruleElement.findall('.sub'): 701 a = subElement.attrib['name'] 702 b = subElement.attrib['with'] 703 ruleObject.subs.append((a, b)) 704 rules.append(ruleObject) 705 self.documentObject.rules = rules 706 707 def _readConditionElements(self, parentElement, ruleName=None): 708 cds = [] 709 for conditionElement in parentElement.findall('.condition'): 710 cd = {} 711 cdMin = conditionElement.attrib.get("minimum") 712 if cdMin is not None: 713 cd['minimum'] = float(cdMin) 714 else: 715 # will allow these to be None, assume axis.minimum 716 cd['minimum'] = None 717 cdMax = conditionElement.attrib.get("maximum") 718 if cdMax is not None: 719 cd['maximum'] = float(cdMax) 720 else: 721 # will allow these to be None, assume axis.maximum 722 cd['maximum'] = None 723 cd['name'] = conditionElement.attrib.get("name") 724 # # test for things 725 if cd.get('minimum') is None and cd.get('maximum') is None: 726 raise DesignSpaceDocumentError( 727 "condition missing required minimum or maximum in rule" + 728 (" '%s'" % ruleName if ruleName is not None else "")) 729 cds.append(cd) 730 return cds 731 732 def readAxes(self): 733 # read the axes elements, including the warp map. 734 axisElements = self.root.findall(".axes/axis") 735 if not axisElements: 736 return 737 for axisElement in axisElements: 738 axisObject = self.axisDescriptorClass() 739 axisObject.name = axisElement.attrib.get("name") 740 axisObject.minimum = float(axisElement.attrib.get("minimum")) 741 axisObject.maximum = float(axisElement.attrib.get("maximum")) 742 if axisElement.attrib.get('hidden', False): 743 axisObject.hidden = True 744 axisObject.default = float(axisElement.attrib.get("default")) 745 axisObject.tag = axisElement.attrib.get("tag") 746 for mapElement in axisElement.findall('map'): 747 a = float(mapElement.attrib['input']) 748 b = float(mapElement.attrib['output']) 749 axisObject.map.append((a, b)) 750 for labelNameElement in axisElement.findall('labelname'): 751 # Note: elementtree reads the "xml:lang" attribute name as 752 # '{http://www.w3.org/XML/1998/namespace}lang' 753 for key, lang in labelNameElement.items(): 754 if key == XML_LANG: 755 axisObject.labelNames[lang] = tounicode(labelNameElement.text) 756 self.documentObject.axes.append(axisObject) 757 self.axisDefaults[axisObject.name] = axisObject.default 758 759 def readSources(self): 760 for sourceCount, sourceElement in enumerate(self.root.findall(".sources/source")): 761 filename = sourceElement.attrib.get('filename') 762 if filename is not None and self.path is not None: 763 sourcePath = os.path.abspath(os.path.join(os.path.dirname(self.path), filename)) 764 else: 765 sourcePath = None 766 sourceName = sourceElement.attrib.get('name') 767 if sourceName is None: 768 # add a temporary source name 769 sourceName = "temp_master.%d" % (sourceCount) 770 sourceObject = self.sourceDescriptorClass() 771 sourceObject.path = sourcePath # absolute path to the ufo source 772 sourceObject.filename = filename # path as it is stored in the document 773 sourceObject.name = sourceName 774 familyName = sourceElement.attrib.get("familyname") 775 if familyName is not None: 776 sourceObject.familyName = familyName 777 styleName = sourceElement.attrib.get("stylename") 778 if styleName is not None: 779 sourceObject.styleName = styleName 780 sourceObject.location = self.locationFromElement(sourceElement) 781 layerName = sourceElement.attrib.get('layer') 782 if layerName is not None: 783 sourceObject.layerName = layerName 784 for libElement in sourceElement.findall('.lib'): 785 if libElement.attrib.get('copy') == '1': 786 sourceObject.copyLib = True 787 for groupsElement in sourceElement.findall('.groups'): 788 if groupsElement.attrib.get('copy') == '1': 789 sourceObject.copyGroups = True 790 for infoElement in sourceElement.findall(".info"): 791 if infoElement.attrib.get('copy') == '1': 792 sourceObject.copyInfo = True 793 if infoElement.attrib.get('mute') == '1': 794 sourceObject.muteInfo = True 795 for featuresElement in sourceElement.findall(".features"): 796 if featuresElement.attrib.get('copy') == '1': 797 sourceObject.copyFeatures = True 798 for glyphElement in sourceElement.findall(".glyph"): 799 glyphName = glyphElement.attrib.get('name') 800 if glyphName is None: 801 continue 802 if glyphElement.attrib.get('mute') == '1': 803 sourceObject.mutedGlyphNames.append(glyphName) 804 for kerningElement in sourceElement.findall(".kerning"): 805 if kerningElement.attrib.get('mute') == '1': 806 sourceObject.muteKerning = True 807 self.documentObject.sources.append(sourceObject) 808 809 def locationFromElement(self, element): 810 elementLocation = None 811 for locationElement in element.findall('.location'): 812 elementLocation = self.readLocationElement(locationElement) 813 break 814 return elementLocation 815 816 def readLocationElement(self, locationElement): 817 """ Format 0 location reader """ 818 if self._strictAxisNames and not self.documentObject.axes: 819 raise DesignSpaceDocumentError("No axes defined") 820 loc = {} 821 for dimensionElement in locationElement.findall(".dimension"): 822 dimName = dimensionElement.attrib.get("name") 823 if self._strictAxisNames and dimName not in self.axisDefaults: 824 # In case the document contains no axis definitions, 825 self.log.warning("Location with undefined axis: \"%s\".", dimName) 826 continue 827 xValue = yValue = None 828 try: 829 xValue = dimensionElement.attrib.get('xvalue') 830 xValue = float(xValue) 831 except ValueError: 832 self.log.warning("KeyError in readLocation xValue %3.3f", xValue) 833 try: 834 yValue = dimensionElement.attrib.get('yvalue') 835 if yValue is not None: 836 yValue = float(yValue) 837 except ValueError: 838 pass 839 if yValue is not None: 840 loc[dimName] = (xValue, yValue) 841 else: 842 loc[dimName] = xValue 843 return loc 844 845 def readInstances(self, makeGlyphs=True, makeKerning=True, makeInfo=True): 846 instanceElements = self.root.findall('.instances/instance') 847 for instanceElement in instanceElements: 848 self._readSingleInstanceElement(instanceElement, makeGlyphs=makeGlyphs, makeKerning=makeKerning, makeInfo=makeInfo) 849 850 def _readSingleInstanceElement(self, instanceElement, makeGlyphs=True, makeKerning=True, makeInfo=True): 851 filename = instanceElement.attrib.get('filename') 852 if filename is not None and self.documentObject.path is not None: 853 instancePath = os.path.join(os.path.dirname(self.documentObject.path), filename) 854 else: 855 instancePath = None 856 instanceObject = self.instanceDescriptorClass() 857 instanceObject.path = instancePath # absolute path to the instance 858 instanceObject.filename = filename # path as it is stored in the document 859 name = instanceElement.attrib.get("name") 860 if name is not None: 861 instanceObject.name = name 862 familyname = instanceElement.attrib.get('familyname') 863 if familyname is not None: 864 instanceObject.familyName = familyname 865 stylename = instanceElement.attrib.get('stylename') 866 if stylename is not None: 867 instanceObject.styleName = stylename 868 postScriptFontName = instanceElement.attrib.get('postscriptfontname') 869 if postScriptFontName is not None: 870 instanceObject.postScriptFontName = postScriptFontName 871 styleMapFamilyName = instanceElement.attrib.get('stylemapfamilyname') 872 if styleMapFamilyName is not None: 873 instanceObject.styleMapFamilyName = styleMapFamilyName 874 styleMapStyleName = instanceElement.attrib.get('stylemapstylename') 875 if styleMapStyleName is not None: 876 instanceObject.styleMapStyleName = styleMapStyleName 877 # read localised names 878 for styleNameElement in instanceElement.findall('stylename'): 879 for key, lang in styleNameElement.items(): 880 if key == XML_LANG: 881 styleName = styleNameElement.text 882 instanceObject.setStyleName(styleName, lang) 883 for familyNameElement in instanceElement.findall('familyname'): 884 for key, lang in familyNameElement.items(): 885 if key == XML_LANG: 886 familyName = familyNameElement.text 887 instanceObject.setFamilyName(familyName, lang) 888 for styleMapStyleNameElement in instanceElement.findall('stylemapstylename'): 889 for key, lang in styleMapStyleNameElement.items(): 890 if key == XML_LANG: 891 styleMapStyleName = styleMapStyleNameElement.text 892 instanceObject.setStyleMapStyleName(styleMapStyleName, lang) 893 for styleMapFamilyNameElement in instanceElement.findall('stylemapfamilyname'): 894 for key, lang in styleMapFamilyNameElement.items(): 895 if key == XML_LANG: 896 styleMapFamilyName = styleMapFamilyNameElement.text 897 instanceObject.setStyleMapFamilyName(styleMapFamilyName, lang) 898 instanceLocation = self.locationFromElement(instanceElement) 899 if instanceLocation is not None: 900 instanceObject.location = instanceLocation 901 for glyphElement in instanceElement.findall('.glyphs/glyph'): 902 self.readGlyphElement(glyphElement, instanceObject) 903 for infoElement in instanceElement.findall("info"): 904 self.readInfoElement(infoElement, instanceObject) 905 for libElement in instanceElement.findall('lib'): 906 self.readLibElement(libElement, instanceObject) 907 self.documentObject.instances.append(instanceObject) 908 909 def readLibElement(self, libElement, instanceObject): 910 """Read the lib element for the given instance.""" 911 instanceObject.lib = plistlib.fromtree(libElement[0]) 912 913 def readInfoElement(self, infoElement, instanceObject): 914 """ Read the info element.""" 915 instanceObject.info = True 916 917 def readKerningElement(self, kerningElement, instanceObject): 918 """ Read the kerning element.""" 919 kerningLocation = self.locationFromElement(kerningElement) 920 instanceObject.addKerning(kerningLocation) 921 922 def readGlyphElement(self, glyphElement, instanceObject): 923 """ 924 Read the glyph element. 925 <glyph name="b" unicode="0x62"/> 926 <glyph name="b"/> 927 <glyph name="b"> 928 <master location="location-token-bbb" source="master-token-aaa2"/> 929 <master glyphname="b.alt1" location="location-token-ccc" source="master-token-aaa3"/> 930 <note> 931 This is an instance from an anisotropic interpolation. 932 </note> 933 </glyph> 934 """ 935 glyphData = {} 936 glyphName = glyphElement.attrib.get('name') 937 if glyphName is None: 938 raise DesignSpaceDocumentError("Glyph object without name attribute") 939 mute = glyphElement.attrib.get("mute") 940 if mute == "1": 941 glyphData['mute'] = True 942 # unicode 943 unicodes = glyphElement.attrib.get('unicode') 944 if unicodes is not None: 945 try: 946 unicodes = [int(u, 16) for u in unicodes.split(" ")] 947 glyphData['unicodes'] = unicodes 948 except ValueError: 949 raise DesignSpaceDocumentError("unicode values %s are not integers" % unicodes) 950 951 for noteElement in glyphElement.findall('.note'): 952 glyphData['note'] = noteElement.text 953 break 954 instanceLocation = self.locationFromElement(glyphElement) 955 if instanceLocation is not None: 956 glyphData['instanceLocation'] = instanceLocation 957 glyphSources = None 958 for masterElement in glyphElement.findall('.masters/master'): 959 fontSourceName = masterElement.attrib.get('source') 960 sourceLocation = self.locationFromElement(masterElement) 961 masterGlyphName = masterElement.attrib.get('glyphname') 962 if masterGlyphName is None: 963 # if we don't read a glyphname, use the one we have 964 masterGlyphName = glyphName 965 d = dict(font=fontSourceName, 966 location=sourceLocation, 967 glyphName=masterGlyphName) 968 if glyphSources is None: 969 glyphSources = [] 970 glyphSources.append(d) 971 if glyphSources is not None: 972 glyphData['masters'] = glyphSources 973 instanceObject.glyphs[glyphName] = glyphData 974 975 def readLib(self): 976 """Read the lib element for the whole document.""" 977 for libElement in self.root.findall(".lib"): 978 self.documentObject.lib = plistlib.fromtree(libElement[0]) 979 980 981class DesignSpaceDocument(LogMixin, AsDictMixin): 982 """ Read, write data from the designspace file""" 983 def __init__(self, readerClass=None, writerClass=None): 984 self.path = None 985 self.filename = None 986 """String, optional. When the document is read from the disk, this is 987 its original file name, i.e. the last part of its path. 988 989 When the document is produced by a Python script and still only exists 990 in memory, the producing script can write here an indication of a 991 possible "good" filename, in case one wants to save the file somewhere. 992 """ 993 994 self.formatVersion = None 995 self.sources = [] 996 self.instances = [] 997 self.axes = [] 998 self.rules = [] 999 self.default = None # name of the default master 1000 1001 self.lib = {} 1002 """Custom data associated with the whole document.""" 1003 1004 # 1005 if readerClass is not None: 1006 self.readerClass = readerClass 1007 else: 1008 self.readerClass = BaseDocReader 1009 if writerClass is not None: 1010 self.writerClass = writerClass 1011 else: 1012 self.writerClass = BaseDocWriter 1013 1014 @classmethod 1015 def fromfile(cls, path, readerClass=None, writerClass=None): 1016 self = cls(readerClass=readerClass, writerClass=writerClass) 1017 self.read(path) 1018 return self 1019 1020 @classmethod 1021 def fromstring(cls, string, readerClass=None, writerClass=None): 1022 self = cls(readerClass=readerClass, writerClass=writerClass) 1023 reader = self.readerClass.fromstring(string, self) 1024 reader.read() 1025 if self.sources: 1026 self.findDefault() 1027 return self 1028 1029 def tostring(self, encoding=None): 1030 if encoding is unicode or ( 1031 encoding is not None and encoding.lower() == "unicode" 1032 ): 1033 f = UnicodeIO() 1034 xml_declaration = False 1035 elif encoding is None or encoding == "utf-8": 1036 f = BytesIO() 1037 encoding = "UTF-8" 1038 xml_declaration = True 1039 else: 1040 raise ValueError("unsupported encoding: '%s'" % encoding) 1041 writer = self.writerClass(f, self) 1042 writer.write(encoding=encoding, xml_declaration=xml_declaration) 1043 return f.getvalue() 1044 1045 def read(self, path): 1046 if hasattr(path, "__fspath__"): # support os.PathLike objects 1047 path = path.__fspath__() 1048 self.path = path 1049 self.filename = os.path.basename(path) 1050 reader = self.readerClass(path, self) 1051 reader.read() 1052 if self.sources: 1053 self.findDefault() 1054 1055 def write(self, path): 1056 if hasattr(path, "__fspath__"): # support os.PathLike objects 1057 path = path.__fspath__() 1058 self.path = path 1059 self.filename = os.path.basename(path) 1060 self.updatePaths() 1061 writer = self.writerClass(path, self) 1062 writer.write() 1063 1064 def _posixRelativePath(self, otherPath): 1065 relative = os.path.relpath(otherPath, os.path.dirname(self.path)) 1066 return posix(relative) 1067 1068 def updatePaths(self): 1069 """ 1070 Right before we save we need to identify and respond to the following situations: 1071 In each descriptor, we have to do the right thing for the filename attribute. 1072 1073 case 1. 1074 descriptor.filename == None 1075 descriptor.path == None 1076 1077 -- action: 1078 write as is, descriptors will not have a filename attr. 1079 useless, but no reason to interfere. 1080 1081 1082 case 2. 1083 descriptor.filename == "../something" 1084 descriptor.path == None 1085 1086 -- action: 1087 write as is. The filename attr should not be touched. 1088 1089 1090 case 3. 1091 descriptor.filename == None 1092 descriptor.path == "~/absolute/path/there" 1093 1094 -- action: 1095 calculate the relative path for filename. 1096 We're not overwriting some other value for filename, it should be fine 1097 1098 1099 case 4. 1100 descriptor.filename == '../somewhere' 1101 descriptor.path == "~/absolute/path/there" 1102 1103 -- action: 1104 there is a conflict between the given filename, and the path. 1105 So we know where the file is relative to the document. 1106 Can't guess why they're different, we just choose for path to be correct and update filename. 1107 1108 1109 """ 1110 assert self.path is not None 1111 for descriptor in self.sources + self.instances: 1112 if descriptor.path is not None: 1113 # case 3 and 4: filename gets updated and relativized 1114 descriptor.filename = self._posixRelativePath(descriptor.path) 1115 1116 def addSource(self, sourceDescriptor): 1117 self.sources.append(sourceDescriptor) 1118 1119 def addInstance(self, instanceDescriptor): 1120 self.instances.append(instanceDescriptor) 1121 1122 def addAxis(self, axisDescriptor): 1123 self.axes.append(axisDescriptor) 1124 1125 def addRule(self, ruleDescriptor): 1126 self.rules.append(ruleDescriptor) 1127 1128 def newDefaultLocation(self): 1129 """Return default location in design space.""" 1130 # Without OrderedDict, output XML would be non-deterministic. 1131 # https://github.com/LettError/designSpaceDocument/issues/10 1132 loc = collections.OrderedDict() 1133 for axisDescriptor in self.axes: 1134 loc[axisDescriptor.name] = axisDescriptor.map_forward( 1135 axisDescriptor.default 1136 ) 1137 return loc 1138 1139 def updateFilenameFromPath(self, masters=True, instances=True, force=False): 1140 # set a descriptor filename attr from the path and this document path 1141 # if the filename attribute is not None: skip it. 1142 if masters: 1143 for descriptor in self.sources: 1144 if descriptor.filename is not None and not force: 1145 continue 1146 if self.path is not None: 1147 descriptor.filename = self._posixRelativePath(descriptor.path) 1148 if instances: 1149 for descriptor in self.instances: 1150 if descriptor.filename is not None and not force: 1151 continue 1152 if self.path is not None: 1153 descriptor.filename = self._posixRelativePath(descriptor.path) 1154 1155 def newAxisDescriptor(self): 1156 # Ask the writer class to make us a new axisDescriptor 1157 return self.writerClass.getAxisDecriptor() 1158 1159 def newSourceDescriptor(self): 1160 # Ask the writer class to make us a new sourceDescriptor 1161 return self.writerClass.getSourceDescriptor() 1162 1163 def newInstanceDescriptor(self): 1164 # Ask the writer class to make us a new instanceDescriptor 1165 return self.writerClass.getInstanceDescriptor() 1166 1167 def getAxisOrder(self): 1168 names = [] 1169 for axisDescriptor in self.axes: 1170 names.append(axisDescriptor.name) 1171 return names 1172 1173 def getAxis(self, name): 1174 for axisDescriptor in self.axes: 1175 if axisDescriptor.name == name: 1176 return axisDescriptor 1177 return None 1178 1179 def findDefault(self): 1180 """Set and return SourceDescriptor at the default location or None. 1181 1182 The default location is the set of all `default` values in user space 1183 of all axes. 1184 """ 1185 self.default = None 1186 1187 # Convert the default location from user space to design space before comparing 1188 # it against the SourceDescriptor locations (always in design space). 1189 default_location_design = self.newDefaultLocation() 1190 1191 for sourceDescriptor in self.sources: 1192 if sourceDescriptor.location == default_location_design: 1193 self.default = sourceDescriptor 1194 return sourceDescriptor 1195 1196 return None 1197 1198 def normalizeLocation(self, location): 1199 from fontTools.varLib.models import normalizeValue 1200 1201 new = {} 1202 for axis in self.axes: 1203 if axis.name not in location: 1204 # skipping this dimension it seems 1205 continue 1206 value = location[axis.name] 1207 # 'anisotropic' location, take first coord only 1208 if isinstance(value, tuple): 1209 value = value[0] 1210 triple = [ 1211 axis.map_forward(v) for v in (axis.minimum, axis.default, axis.maximum) 1212 ] 1213 new[axis.name] = normalizeValue(value, triple) 1214 return new 1215 1216 def normalize(self): 1217 # Normalise the geometry of this designspace: 1218 # scale all the locations of all masters and instances to the -1 - 0 - 1 value. 1219 # we need the axis data to do the scaling, so we do those last. 1220 # masters 1221 for item in self.sources: 1222 item.location = self.normalizeLocation(item.location) 1223 # instances 1224 for item in self.instances: 1225 # glyph masters for this instance 1226 for _, glyphData in item.glyphs.items(): 1227 glyphData['instanceLocation'] = self.normalizeLocation(glyphData['instanceLocation']) 1228 for glyphMaster in glyphData['masters']: 1229 glyphMaster['location'] = self.normalizeLocation(glyphMaster['location']) 1230 item.location = self.normalizeLocation(item.location) 1231 # the axes 1232 for axis in self.axes: 1233 # scale the map first 1234 newMap = [] 1235 for inputValue, outputValue in axis.map: 1236 newOutputValue = self.normalizeLocation({axis.name: outputValue}).get(axis.name) 1237 newMap.append((inputValue, newOutputValue)) 1238 if newMap: 1239 axis.map = newMap 1240 # finally the axis values 1241 minimum = self.normalizeLocation({axis.name: axis.minimum}).get(axis.name) 1242 maximum = self.normalizeLocation({axis.name: axis.maximum}).get(axis.name) 1243 default = self.normalizeLocation({axis.name: axis.default}).get(axis.name) 1244 # and set them in the axis.minimum 1245 axis.minimum = minimum 1246 axis.maximum = maximum 1247 axis.default = default 1248 # now the rules 1249 for rule in self.rules: 1250 newConditionSets = [] 1251 for conditions in rule.conditionSets: 1252 newConditions = [] 1253 for cond in conditions: 1254 if cond.get('minimum') is not None: 1255 minimum = self.normalizeLocation({cond['name']: cond['minimum']}).get(cond['name']) 1256 else: 1257 minimum = None 1258 if cond.get('maximum') is not None: 1259 maximum = self.normalizeLocation({cond['name']: cond['maximum']}).get(cond['name']) 1260 else: 1261 maximum = None 1262 newConditions.append(dict(name=cond['name'], minimum=minimum, maximum=maximum)) 1263 newConditionSets.append(newConditions) 1264 rule.conditionSets = newConditionSets 1265 1266 def loadSourceFonts(self, opener, **kwargs): 1267 """Ensure SourceDescriptor.font attributes are loaded, and return list of fonts. 1268 1269 Takes a callable which initializes a new font object (e.g. TTFont, or 1270 defcon.Font, etc.) from the SourceDescriptor.path, and sets the 1271 SourceDescriptor.font attribute. 1272 If the font attribute is already not None, it is not loaded again. 1273 Fonts with the same path are only loaded once and shared among SourceDescriptors. 1274 1275 For example, to load UFO sources using defcon: 1276 1277 designspace = DesignSpaceDocument.fromfile("path/to/my.designspace") 1278 designspace.loadSourceFonts(defcon.Font) 1279 1280 Or to load masters as FontTools binary fonts, including extra options: 1281 1282 designspace.loadSourceFonts(ttLib.TTFont, recalcBBoxes=False) 1283 1284 Args: 1285 opener (Callable): takes one required positional argument, the source.path, 1286 and an optional list of keyword arguments, and returns a new font object 1287 loaded from the path. 1288 **kwargs: extra options passed on to the opener function. 1289 1290 Returns: 1291 List of font objects in the order they appear in the sources list. 1292 """ 1293 # we load fonts with the same source.path only once 1294 loaded = {} 1295 fonts = [] 1296 for source in self.sources: 1297 if source.font is not None: # font already loaded 1298 fonts.append(source.font) 1299 continue 1300 if source.path in loaded: 1301 source.font = loaded[source.path] 1302 else: 1303 if source.path is None: 1304 raise DesignSpaceDocumentError( 1305 "Designspace source '%s' has no 'path' attribute" 1306 % (source.name or "<Unknown>") 1307 ) 1308 source.font = opener(source.path, **kwargs) 1309 loaded[source.path] = source.font 1310 fonts.append(source.font) 1311 return fonts 1312