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