• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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