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