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