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