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