• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1from __future__ import annotations
2
3import collections
4import copy
5import itertools
6import math
7import os
8import posixpath
9from io import BytesIO, StringIO
10from textwrap import indent
11from typing import Any, Dict, List, MutableMapping, Optional, Tuple, Union, cast
12
13from fontTools.misc import etree as ET
14from fontTools.misc import plistlib
15from fontTools.misc.loggingTools import LogMixin
16from fontTools.misc.textTools import tobytes, tostr
17
18"""
19    designSpaceDocument
20
21    - read and write designspace files
22"""
23
24__all__ = [
25    'AxisDescriptor',
26    'AxisLabelDescriptor',
27    'BaseDocReader',
28    'BaseDocWriter',
29    'DesignSpaceDocument',
30    'DesignSpaceDocumentError',
31    'DiscreteAxisDescriptor',
32    'InstanceDescriptor',
33    'LocationLabelDescriptor',
34    'RangeAxisSubsetDescriptor',
35    'RuleDescriptor',
36    'SourceDescriptor',
37    'ValueAxisSubsetDescriptor',
38    'VariableFontDescriptor',
39]
40
41# ElementTree allows to find namespace-prefixed elements, but not attributes
42# so we have to do it ourselves for 'xml:lang'
43XML_NS = "{http://www.w3.org/XML/1998/namespace}"
44XML_LANG = XML_NS + "lang"
45
46
47def posix(path):
48    """Normalize paths using forward slash to work also on Windows."""
49    new_path = posixpath.join(*path.split(os.path.sep))
50    if path.startswith('/'):
51        # The above transformation loses absolute paths
52        new_path = '/' + new_path
53    elif path.startswith(r'\\'):
54        # The above transformation loses leading slashes of UNC path mounts
55        new_path = '//' + new_path
56    return new_path
57
58
59def posixpath_property(private_name):
60    """Generate a propery that holds a path always using forward slashes."""
61    def getter(self):
62        # Normal getter
63        return getattr(self, private_name)
64
65    def setter(self, value):
66        # The setter rewrites paths using forward slashes
67        if value is not None:
68            value = posix(value)
69        setattr(self, private_name, value)
70
71    return property(getter, setter)
72
73
74class DesignSpaceDocumentError(Exception):
75    def __init__(self, msg, obj=None):
76        self.msg = msg
77        self.obj = obj
78
79    def __str__(self):
80        return str(self.msg) + (
81            ": %r" % self.obj if self.obj is not None else "")
82
83
84class AsDictMixin(object):
85
86    def asdict(self):
87        d = {}
88        for attr, value in self.__dict__.items():
89            if attr.startswith("_"):
90                continue
91            if hasattr(value, "asdict"):
92                value = value.asdict()
93            elif isinstance(value, list):
94                value = [
95                    v.asdict() if hasattr(v, "asdict") else v for v in value
96                ]
97            d[attr] = value
98        return d
99
100
101class SimpleDescriptor(AsDictMixin):
102    """ Containers for a bunch of attributes"""
103
104    # XXX this is ugly. The 'print' is inappropriate here, and instead of
105    # assert, it should simply return True/False
106    def compare(self, other):
107        # test if this object contains the same data as the other
108        for attr in self._attrs:
109            try:
110                assert(getattr(self, attr) == getattr(other, attr))
111            except AssertionError:
112                print("failed attribute", attr, getattr(self, attr), "!=", getattr(other, attr))
113
114    def __repr__(self):
115        attrs = [f"{a}={repr(getattr(self, a))}," for a in self._attrs]
116        attrs = indent('\n'.join(attrs), '    ')
117        return f"{self.__class__.__name__}(\n{attrs}\n)"
118
119
120class SourceDescriptor(SimpleDescriptor):
121    """Simple container for data related to the source
122
123    .. code:: python
124
125        doc = DesignSpaceDocument()
126        s1 = SourceDescriptor()
127        s1.path = masterPath1
128        s1.name = "master.ufo1"
129        s1.font = defcon.Font("master.ufo1")
130        s1.location = dict(weight=0)
131        s1.familyName = "MasterFamilyName"
132        s1.styleName = "MasterStyleNameOne"
133        s1.localisedFamilyName = dict(fr="Caractère")
134        s1.mutedGlyphNames.append("A")
135        s1.mutedGlyphNames.append("Z")
136        doc.addSource(s1)
137
138    """
139    flavor = "source"
140    _attrs = ['filename', 'path', 'name', 'layerName',
141              'location', 'copyLib',
142              'copyGroups', 'copyFeatures',
143              'muteKerning', 'muteInfo',
144              'mutedGlyphNames',
145              'familyName', 'styleName', 'localisedFamilyName']
146
147    filename = posixpath_property("_filename")
148    path = posixpath_property("_path")
149
150    def __init__(
151        self,
152        *,
153        filename=None,
154        path=None,
155        font=None,
156        name=None,
157        location=None,
158        designLocation=None,
159        layerName=None,
160        familyName=None,
161        styleName=None,
162        localisedFamilyName=None,
163        copyLib=False,
164        copyInfo=False,
165        copyGroups=False,
166        copyFeatures=False,
167        muteKerning=False,
168        muteInfo=False,
169        mutedGlyphNames=None,
170    ):
171        self.filename = filename
172        """string. A relative path to the source file, **as it is in the document**.
173
174        MutatorMath + VarLib.
175        """
176        self.path = path
177        """The absolute path, calculated from filename."""
178
179        self.font = font
180        """Any Python object. Optional. Points to a representation of this
181        source font that is loaded in memory, as a Python object (e.g. a
182        ``defcon.Font`` or a ``fontTools.ttFont.TTFont``).
183
184        The default document reader will not fill-in this attribute, and the
185        default writer will not use this attribute. It is up to the user of
186        ``designspaceLib`` to either load the resource identified by
187        ``filename`` and store it in this field, or write the contents of
188        this field to the disk and make ```filename`` point to that.
189        """
190
191        self.name = name
192        """string. Optional. Unique identifier name for this source.
193
194        MutatorMath + Varlib.
195        """
196
197        self.designLocation = designLocation if designLocation is not None else location or {}
198        """dict. Axis values for this source, in design space coordinates.
199
200        MutatorMath + Varlib.
201
202        This may be only part of the full design location.
203        See :meth:`getFullDesignLocation()`
204
205        .. versionadded:: 5.0
206        """
207
208        self.layerName = layerName
209        """string. The name of the layer in the source to look for
210        outline data. Default ``None`` which means ``foreground``.
211        """
212        self.familyName = familyName
213        """string. Family name of this source. Though this data
214        can be extracted from the font, it can be efficient to have it right
215        here.
216
217        Varlib.
218        """
219        self.styleName = styleName
220        """string. Style name of this source. Though this data
221        can be extracted from the font, it can be efficient to have it right
222        here.
223
224        Varlib.
225        """
226        self.localisedFamilyName = localisedFamilyName or {}
227        """dict. A dictionary of localised family name strings, keyed by
228        language code.
229
230        If present, will be used to build localized names for all instances.
231
232        .. versionadded:: 5.0
233        """
234
235        self.copyLib = copyLib
236        """bool. Indicates if the contents of the font.lib need to
237        be copied to the instances.
238
239        MutatorMath.
240
241        .. deprecated:: 5.0
242        """
243        self.copyInfo = copyInfo
244        """bool. Indicates if the non-interpolating font.info needs
245        to be copied to the instances.
246
247        MutatorMath.
248
249        .. deprecated:: 5.0
250        """
251        self.copyGroups = copyGroups
252        """bool. Indicates if the groups need to be copied to the
253        instances.
254
255        MutatorMath.
256
257        .. deprecated:: 5.0
258        """
259        self.copyFeatures = copyFeatures
260        """bool. Indicates if the feature text needs to be
261        copied to the instances.
262
263        MutatorMath.
264
265        .. deprecated:: 5.0
266        """
267        self.muteKerning = muteKerning
268        """bool. Indicates if the kerning data from this source
269        needs to be muted (i.e. not be part of the calculations).
270
271        MutatorMath only.
272        """
273        self.muteInfo = muteInfo
274        """bool. Indicated if the interpolating font.info data for
275        this source needs to be muted.
276
277        MutatorMath only.
278        """
279        self.mutedGlyphNames = mutedGlyphNames or []
280        """list. Glyphnames that need to be muted in the
281        instances.
282
283        MutatorMath only.
284        """
285
286    @property
287    def location(self):
288        """dict. Axis values for this source, in design space coordinates.
289
290        MutatorMath + Varlib.
291
292        .. deprecated:: 5.0
293           Use the more explicit alias for this property :attr:`designLocation`.
294        """
295        return self.designLocation
296
297    @location.setter
298    def location(self, location: Optional[AnisotropicLocationDict]):
299        self.designLocation = location or {}
300
301    def setFamilyName(self, familyName, languageCode="en"):
302        """Setter for :attr:`localisedFamilyName`
303
304        .. versionadded:: 5.0
305        """
306        self.localisedFamilyName[languageCode] = tostr(familyName)
307
308    def getFamilyName(self, languageCode="en"):
309        """Getter for :attr:`localisedFamilyName`
310
311        .. versionadded:: 5.0
312        """
313        return self.localisedFamilyName.get(languageCode)
314
315
316    def getFullDesignLocation(self, doc: 'DesignSpaceDocument') -> AnisotropicLocationDict:
317        """Get the complete design location of this source, from its
318        :attr:`designLocation` and the document's axis defaults.
319
320        .. versionadded:: 5.0
321        """
322        result: AnisotropicLocationDict = {}
323        for axis in doc.axes:
324            if axis.name in self.designLocation:
325                result[axis.name] = self.designLocation[axis.name]
326            else:
327                result[axis.name] = axis.map_forward(axis.default)
328        return result
329
330
331class RuleDescriptor(SimpleDescriptor):
332    """Represents the rule descriptor element: a set of glyph substitutions to
333    trigger conditionally in some parts of the designspace.
334
335    .. code:: python
336
337        r1 = RuleDescriptor()
338        r1.name = "unique.rule.name"
339        r1.conditionSets.append([dict(name="weight", minimum=-10, maximum=10), dict(...)])
340        r1.conditionSets.append([dict(...), dict(...)])
341        r1.subs.append(("a", "a.alt"))
342
343    .. code:: xml
344
345        <!-- optional: list of substitution rules -->
346        <rules>
347            <rule name="vertical.bars">
348                <conditionset>
349                    <condition minimum="250.000000" maximum="750.000000" name="weight"/>
350                    <condition minimum="100" name="width"/>
351                    <condition minimum="10" maximum="40" name="optical"/>
352                </conditionset>
353                <sub name="cent" with="cent.alt"/>
354                <sub name="dollar" with="dollar.alt"/>
355            </rule>
356        </rules>
357    """
358    _attrs = ['name', 'conditionSets', 'subs']   # what do we need here
359
360    def __init__(self, *, name=None, conditionSets=None, subs=None):
361        self.name = name
362        """string. Unique name for this rule. Can be used to reference this rule data."""
363        # list of lists of dict(name='aaaa', minimum=0, maximum=1000)
364        self.conditionSets = conditionSets or []
365        """a list of conditionsets.
366
367        -  Each conditionset is a list of conditions.
368        -  Each condition is a dict with ``name``, ``minimum`` and ``maximum`` keys.
369        """
370        # list of substitutions stored as tuples of glyphnames ("a", "a.alt")
371        self.subs = subs or []
372        """list of substitutions.
373
374        -  Each substitution is stored as tuples of glyphnames, e.g. ("a", "a.alt").
375        -  Note: By default, rules are applied first, before other text
376           shaping/OpenType layout, as they are part of the
377           `Required Variation Alternates OpenType feature <https://docs.microsoft.com/en-us/typography/opentype/spec/features_pt#-tag-rvrn>`_.
378           See ref:`rules-element` § Attributes.
379        """
380
381
382def evaluateRule(rule, location):
383    """Return True if any of the rule's conditionsets matches the given location."""
384    return any(evaluateConditions(c, location) for c in rule.conditionSets)
385
386
387def evaluateConditions(conditions, location):
388    """Return True if all the conditions matches the given location.
389
390    - If a condition has no minimum, check for < maximum.
391    - If a condition has no maximum, check for > minimum.
392    """
393    for cd in conditions:
394        value = location[cd['name']]
395        if cd.get('minimum') is None:
396            if value > cd['maximum']:
397                return False
398        elif cd.get('maximum') is None:
399            if cd['minimum'] > value:
400                return False
401        elif not cd['minimum'] <= value <= cd['maximum']:
402            return False
403    return True
404
405
406def processRules(rules, location, glyphNames):
407    """Apply these rules at this location to these glyphnames.
408
409    Return a new list of glyphNames with substitutions applied.
410
411    - rule order matters
412    """
413    newNames = []
414    for rule in rules:
415        if evaluateRule(rule, location):
416            for name in glyphNames:
417                swap = False
418                for a, b in rule.subs:
419                    if name == a:
420                        swap = True
421                        break
422                if swap:
423                    newNames.append(b)
424                else:
425                    newNames.append(name)
426            glyphNames = newNames
427            newNames = []
428    return glyphNames
429
430
431AnisotropicLocationDict = Dict[str, Union[float, Tuple[float, float]]]
432SimpleLocationDict = Dict[str, float]
433
434
435class InstanceDescriptor(SimpleDescriptor):
436    """Simple container for data related to the instance
437
438
439    .. code:: python
440
441        i2 = InstanceDescriptor()
442        i2.path = instancePath2
443        i2.familyName = "InstanceFamilyName"
444        i2.styleName = "InstanceStyleName"
445        i2.name = "instance.ufo2"
446        # anisotropic location
447        i2.designLocation = dict(weight=500, width=(400,300))
448        i2.postScriptFontName = "InstancePostscriptName"
449        i2.styleMapFamilyName = "InstanceStyleMapFamilyName"
450        i2.styleMapStyleName = "InstanceStyleMapStyleName"
451        i2.lib['com.coolDesignspaceApp.specimenText'] = 'Hamburgerwhatever'
452        doc.addInstance(i2)
453    """
454    flavor = "instance"
455    _defaultLanguageCode = "en"
456    _attrs = ['filename',
457              'path',
458              'name',
459              'locationLabel',
460              'designLocation',
461              'userLocation',
462              'familyName',
463              'styleName',
464              'postScriptFontName',
465              'styleMapFamilyName',
466              'styleMapStyleName',
467              'localisedFamilyName',
468              'localisedStyleName',
469              'localisedStyleMapFamilyName',
470              'localisedStyleMapStyleName',
471              'glyphs',
472              'kerning',
473              'info',
474              'lib']
475
476    filename = posixpath_property("_filename")
477    path = posixpath_property("_path")
478
479    def __init__(
480        self,
481        *,
482        filename=None,
483        path=None,
484        font=None,
485        name=None,
486        location=None,
487        locationLabel=None,
488        designLocation=None,
489        userLocation=None,
490        familyName=None,
491        styleName=None,
492        postScriptFontName=None,
493        styleMapFamilyName=None,
494        styleMapStyleName=None,
495        localisedFamilyName=None,
496        localisedStyleName=None,
497        localisedStyleMapFamilyName=None,
498        localisedStyleMapStyleName=None,
499        glyphs=None,
500        kerning=True,
501        info=True,
502        lib=None,
503    ):
504        self.filename = filename
505        """string. Relative path to the instance file, **as it is
506        in the document**. The file may or may not exist.
507
508        MutatorMath + VarLib.
509        """
510        self.path = path
511        """string. Absolute path to the instance file, calculated from
512        the document path and the string in the filename attr. The file may
513        or may not exist.
514
515        MutatorMath.
516        """
517        self.font = font
518        """Same as :attr:`SourceDescriptor.font`
519
520        .. seealso:: :attr:`SourceDescriptor.font`
521        """
522        self.name = name
523        """string. Unique identifier name of the instance, used to
524        identify it if it needs to be referenced from elsewhere in the
525        document.
526        """
527        self.locationLabel = locationLabel
528        """Name of a :class:`LocationLabelDescriptor`. If
529        provided, the instance should have the same location as the
530        LocationLabel.
531
532        .. seealso::
533           :meth:`getFullDesignLocation`
534           :meth:`getFullUserLocation`
535
536        .. versionadded:: 5.0
537        """
538        self.designLocation: AnisotropicLocationDict = designLocation if designLocation is not None else (location or {})
539        """dict. Axis values for this instance, in design space coordinates.
540
541        MutatorMath + Varlib.
542
543        .. seealso:: This may be only part of the full location. See:
544           :meth:`getFullDesignLocation`
545           :meth:`getFullUserLocation`
546
547        .. versionadded:: 5.0
548        """
549        self.userLocation: SimpleLocationDict = userLocation or {}
550        """dict. Axis values for this instance, in user space coordinates.
551
552        MutatorMath + Varlib.
553
554        .. seealso:: This may be only part of the full location. See:
555           :meth:`getFullDesignLocation`
556           :meth:`getFullUserLocation`
557
558        .. versionadded:: 5.0
559        """
560        self.familyName = familyName
561        """string. Family name of this instance.
562
563        MutatorMath + Varlib.
564        """
565        self.styleName = styleName
566        """string. Style name of this instance.
567
568        MutatorMath + Varlib.
569        """
570        self.postScriptFontName = postScriptFontName
571        """string. Postscript fontname for this instance.
572
573        MutatorMath + Varlib.
574        """
575        self.styleMapFamilyName = styleMapFamilyName
576        """string. StyleMap familyname for this instance.
577
578        MutatorMath + Varlib.
579        """
580        self.styleMapStyleName = styleMapStyleName
581        """string. StyleMap stylename for this instance.
582
583        MutatorMath + Varlib.
584        """
585        self.localisedFamilyName = localisedFamilyName or {}
586        """dict. A dictionary of localised family name
587        strings, keyed by language code.
588        """
589        self.localisedStyleName = localisedStyleName or {}
590        """dict. A dictionary of localised stylename
591        strings, keyed by language code.
592        """
593        self.localisedStyleMapFamilyName = localisedStyleMapFamilyName or {}
594        """A dictionary of localised style map
595        familyname strings, keyed by language code.
596        """
597        self.localisedStyleMapStyleName = localisedStyleMapStyleName or {}
598        """A dictionary of localised style map
599        stylename strings, keyed by language code.
600        """
601        self.glyphs = glyphs or {}
602        """dict for special master definitions for glyphs. If glyphs
603        need special masters (to record the results of executed rules for
604        example).
605
606        MutatorMath.
607
608        .. deprecated:: 5.0
609            Use rules or sparse sources instead.
610        """
611        self.kerning = kerning
612        """ bool. Indicates if this instance needs its kerning
613        calculated.
614
615        MutatorMath.
616
617        .. deprecated:: 5.0
618        """
619        self.info = info
620        """bool. Indicated if this instance needs the interpolating
621        font.info calculated.
622
623        .. deprecated:: 5.0
624        """
625
626        self.lib = lib or {}
627        """Custom data associated with this instance."""
628
629    @property
630    def location(self):
631        """dict. Axis values for this instance.
632
633        MutatorMath + Varlib.
634
635        .. deprecated:: 5.0
636           Use the more explicit alias for this property :attr:`designLocation`.
637        """
638        return self.designLocation
639
640    @location.setter
641    def location(self, location: Optional[AnisotropicLocationDict]):
642        self.designLocation = location or {}
643
644    def setStyleName(self, styleName, languageCode="en"):
645        """These methods give easier access to the localised names."""
646        self.localisedStyleName[languageCode] = tostr(styleName)
647
648    def getStyleName(self, languageCode="en"):
649        return self.localisedStyleName.get(languageCode)
650
651    def setFamilyName(self, familyName, languageCode="en"):
652        self.localisedFamilyName[languageCode] = tostr(familyName)
653
654    def getFamilyName(self, languageCode="en"):
655        return self.localisedFamilyName.get(languageCode)
656
657    def setStyleMapStyleName(self, styleMapStyleName, languageCode="en"):
658        self.localisedStyleMapStyleName[languageCode] = tostr(styleMapStyleName)
659
660    def getStyleMapStyleName(self, languageCode="en"):
661        return self.localisedStyleMapStyleName.get(languageCode)
662
663    def setStyleMapFamilyName(self, styleMapFamilyName, languageCode="en"):
664        self.localisedStyleMapFamilyName[languageCode] = tostr(styleMapFamilyName)
665
666    def getStyleMapFamilyName(self, languageCode="en"):
667        return self.localisedStyleMapFamilyName.get(languageCode)
668
669    def clearLocation(self, axisName: Optional[str] = None):
670        """Clear all location-related fields. Ensures that
671        :attr:``designLocation`` and :attr:``userLocation`` are dictionaries
672        (possibly empty if clearing everything).
673
674        In order to update the location of this instance wholesale, a user
675        should first clear all the fields, then change the field(s) for which
676        they have data.
677
678        .. code:: python
679
680            instance.clearLocation()
681            instance.designLocation = {'Weight': (34, 36.5), 'Width': 100}
682            instance.userLocation = {'Opsz': 16}
683
684        In order to update a single axis location, the user should only clear
685        that axis, then edit the values:
686
687        .. code:: python
688
689            instance.clearLocation('Weight')
690            instance.designLocation['Weight'] = (34, 36.5)
691
692        Args:
693          axisName: if provided, only clear the location for that axis.
694
695        .. versionadded:: 5.0
696        """
697        self.locationLabel = None
698        if axisName is None:
699            self.designLocation = {}
700            self.userLocation = {}
701        else:
702            if self.designLocation is None:
703                self.designLocation = {}
704            if axisName in self.designLocation:
705                del self.designLocation[axisName]
706            if self.userLocation is None:
707                self.userLocation = {}
708            if axisName in self.userLocation:
709                del self.userLocation[axisName]
710
711    def getLocationLabelDescriptor(self, doc: 'DesignSpaceDocument') -> Optional[LocationLabelDescriptor]:
712        """Get the :class:`LocationLabelDescriptor` instance that matches
713        this instances's :attr:`locationLabel`.
714
715        Raises if the named label can't be found.
716
717        .. versionadded:: 5.0
718        """
719        if self.locationLabel is None:
720            return None
721        label = doc.getLocationLabel(self.locationLabel)
722        if label is None:
723            raise DesignSpaceDocumentError(
724                'InstanceDescriptor.getLocationLabelDescriptor(): '
725                f'unknown location label `{self.locationLabel}` in instance `{self.name}`.'
726            )
727        return label
728
729    def getFullDesignLocation(self, doc: 'DesignSpaceDocument') -> AnisotropicLocationDict:
730        """Get the complete design location of this instance, by combining data
731        from the various location fields, default axis values and mappings, and
732        top-level location labels.
733
734        The source of truth for this instance's location is determined for each
735        axis independently by taking the first not-None field in this list:
736
737        - ``locationLabel``: the location along this axis is the same as the
738          matching STAT format 4 label. No anisotropy.
739        - ``designLocation[axisName]``: the explicit design location along this
740          axis, possibly anisotropic.
741        - ``userLocation[axisName]``: the explicit user location along this
742          axis. No anisotropy.
743        - ``axis.default``: default axis value. No anisotropy.
744
745        .. versionadded:: 5.0
746        """
747        label = self.getLocationLabelDescriptor(doc)
748        if label is not None:
749            return doc.map_forward(label.userLocation)  # type: ignore
750        result: AnisotropicLocationDict = {}
751        for axis in doc.axes:
752            if axis.name in self.designLocation:
753                result[axis.name] = self.designLocation[axis.name]
754            elif axis.name in self.userLocation:
755                result[axis.name] = axis.map_forward(self.userLocation[axis.name])
756            else:
757                result[axis.name] = axis.map_forward(axis.default)
758        return result
759
760    def getFullUserLocation(self, doc: 'DesignSpaceDocument') -> SimpleLocationDict:
761        """Get the complete user location for this instance.
762
763        .. seealso:: :meth:`getFullDesignLocation`
764
765        .. versionadded:: 5.0
766        """
767        return doc.map_backward(self.getFullDesignLocation(doc))
768
769
770def tagForAxisName(name):
771    # try to find or make a tag name for this axis name
772    names = {
773        'weight':   ('wght', dict(en = 'Weight')),
774        'width':    ('wdth', dict(en = 'Width')),
775        'optical':  ('opsz', dict(en = 'Optical Size')),
776        'slant':    ('slnt', dict(en = 'Slant')),
777        'italic':   ('ital', dict(en = 'Italic')),
778    }
779    if name.lower() in names:
780        return names[name.lower()]
781    if len(name) < 4:
782        tag = name + "*" * (4 - len(name))
783    else:
784        tag = name[:4]
785    return tag, dict(en=name)
786
787
788class AbstractAxisDescriptor(SimpleDescriptor):
789    flavor = "axis"
790
791    def __init__(
792        self,
793        *,
794        tag=None,
795        name=None,
796        labelNames=None,
797        hidden=False,
798        map=None,
799        axisOrdering=None,
800        axisLabels=None,
801    ):
802        # opentype tag for this axis
803        self.tag = tag
804        """string. Four letter tag for this axis. Some might be
805        registered at the `OpenType
806        specification <https://www.microsoft.com/typography/otspec/fvar.htm#VAT>`__.
807        Privately-defined axis tags must begin with an uppercase letter and
808        use only uppercase letters or digits.
809        """
810        # name of the axis used in locations
811        self.name = name
812        """string. Name of the axis as it is used in the location dicts.
813
814        MutatorMath + Varlib.
815        """
816        # names for UI purposes, if this is not a standard axis,
817        self.labelNames = labelNames or {}
818        """dict. When defining a non-registered axis, it will be
819        necessary to define user-facing readable names for the axis. Keyed by
820        xml:lang code. Values are required to be ``unicode`` strings, even if
821        they only contain ASCII characters.
822        """
823        self.hidden = hidden
824        """bool. Whether this axis should be hidden in user interfaces.
825        """
826        self.map = map or []
827        """list of input / output values that can describe a warp of user space
828        to design space coordinates. If no map values are present, it is assumed
829        user space is the same as design space, as in [(minimum, minimum),
830        (maximum, maximum)].
831
832        Varlib.
833        """
834        self.axisOrdering = axisOrdering
835        """STAT table field ``axisOrdering``.
836
837        See: `OTSpec STAT Axis Record <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-records>`_
838
839        .. versionadded:: 5.0
840        """
841        self.axisLabels: List[AxisLabelDescriptor] = axisLabels or []
842        """STAT table entries for Axis Value Tables format 1, 2, 3.
843
844        See: `OTSpec STAT Axis Value Tables <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-tables>`_
845
846        .. versionadded:: 5.0
847        """
848
849
850class AxisDescriptor(AbstractAxisDescriptor):
851    """ Simple container for the axis data.
852
853    Add more localisations?
854
855    .. code:: python
856
857        a1 = AxisDescriptor()
858        a1.minimum = 1
859        a1.maximum = 1000
860        a1.default = 400
861        a1.name = "weight"
862        a1.tag = "wght"
863        a1.labelNames['fa-IR'] = "قطر"
864        a1.labelNames['en'] = "Wéíght"
865        a1.map = [(1.0, 10.0), (400.0, 66.0), (1000.0, 990.0)]
866        a1.axisOrdering = 1
867        a1.axisLabels = [
868            AxisLabelDescriptor(name="Regular", userValue=400, elidable=True)
869        ]
870        doc.addAxis(a1)
871    """
872    _attrs = ['tag', 'name', 'maximum', 'minimum', 'default', 'map', 'axisOrdering', 'axisLabels']
873
874    def __init__(
875        self,
876        *,
877        tag=None,
878        name=None,
879        labelNames=None,
880        minimum=None,
881        default=None,
882        maximum=None,
883        hidden=False,
884        map=None,
885        axisOrdering=None,
886        axisLabels=None,
887    ):
888        super().__init__(
889            tag=tag,
890            name=name,
891            labelNames=labelNames,
892            hidden=hidden,
893            map=map,
894            axisOrdering=axisOrdering,
895            axisLabels=axisLabels,
896        )
897        self.minimum = minimum
898        """number. The minimum value for this axis in user space.
899
900        MutatorMath + Varlib.
901        """
902        self.maximum = maximum
903        """number. The maximum value for this axis in user space.
904
905        MutatorMath + Varlib.
906        """
907        self.default = default
908        """number. The default value for this axis, i.e. when a new location is
909        created, this is the value this axis will get in user space.
910
911        MutatorMath + Varlib.
912        """
913
914    def serialize(self):
915        # output to a dict, used in testing
916        return dict(
917            tag=self.tag,
918            name=self.name,
919            labelNames=self.labelNames,
920            maximum=self.maximum,
921            minimum=self.minimum,
922            default=self.default,
923            hidden=self.hidden,
924            map=self.map,
925            axisOrdering=self.axisOrdering,
926            axisLabels=self.axisLabels,
927        )
928
929    def map_forward(self, v):
930        """Maps value from axis mapping's input (user) to output (design)."""
931        from fontTools.varLib.models import piecewiseLinearMap
932
933        if not self.map:
934            return v
935        return piecewiseLinearMap(v, {k: v for k, v in self.map})
936
937    def map_backward(self, v):
938        """Maps value from axis mapping's output (design) to input (user)."""
939        from fontTools.varLib.models import piecewiseLinearMap
940
941        if isinstance(v, tuple):
942            v = v[0]
943        if not self.map:
944            return v
945        return piecewiseLinearMap(v, {v: k for k, v in self.map})
946
947
948class DiscreteAxisDescriptor(AbstractAxisDescriptor):
949    """Container for discrete axis data.
950
951    Use this for axes that do not interpolate. The main difference from a
952    continuous axis is that a continuous axis has a ``minimum`` and ``maximum``,
953    while a discrete axis has a list of ``values``.
954
955    Example: an Italic axis with 2 stops, Roman and Italic, that are not
956    compatible. The axis still allows to bind together the full font family,
957    which is useful for the STAT table, however it can't become a variation
958    axis in a VF.
959
960    .. code:: python
961
962        a2 = DiscreteAxisDescriptor()
963        a2.values = [0, 1]
964        a2.default = 0
965        a2.name = "Italic"
966        a2.tag = "ITAL"
967        a2.labelNames['fr'] = "Italique"
968        a2.map = [(0, 0), (1, -11)]
969        a2.axisOrdering = 2
970        a2.axisLabels = [
971            AxisLabelDescriptor(name="Roman", userValue=0, elidable=True)
972        ]
973        doc.addAxis(a2)
974
975    .. versionadded:: 5.0
976    """
977
978    flavor = "axis"
979    _attrs = ('tag', 'name', 'values', 'default', 'map', 'axisOrdering', 'axisLabels')
980
981    def __init__(
982        self,
983        *,
984        tag=None,
985        name=None,
986        labelNames=None,
987        values=None,
988        default=None,
989        hidden=False,
990        map=None,
991        axisOrdering=None,
992        axisLabels=None,
993    ):
994        super().__init__(
995            tag=tag,
996            name=name,
997            labelNames=labelNames,
998            hidden=hidden,
999            map=map,
1000            axisOrdering=axisOrdering,
1001            axisLabels=axisLabels,
1002        )
1003        self.default: float = default
1004        """The default value for this axis, i.e. when a new location is
1005        created, this is the value this axis will get in user space.
1006
1007        However, this default value is less important than in continuous axes:
1008
1009        -  it doesn't define the "neutral" version of outlines from which
1010           deltas would apply, as this axis does not interpolate.
1011        -  it doesn't provide the reference glyph set for the designspace, as
1012           fonts at each value can have different glyph sets.
1013        """
1014        self.values: List[float] = values or []
1015        """List of possible values for this axis. Contrary to continuous axes,
1016        only the values in this list can be taken by the axis, nothing in-between.
1017        """
1018
1019    def map_forward(self, value):
1020        """Maps value from axis mapping's input to output.
1021
1022        Returns value unchanged if no mapping entry is found.
1023
1024        Note: for discrete axes, each value must have its mapping entry, if
1025        you intend that value to be mapped.
1026        """
1027        return next((v for k, v in self.map if k == value), value)
1028
1029    def map_backward(self, value):
1030        """Maps value from axis mapping's output to input.
1031
1032        Returns value unchanged if no mapping entry is found.
1033
1034        Note: for discrete axes, each value must have its mapping entry, if
1035        you intend that value to be mapped.
1036        """
1037        if isinstance(value, tuple):
1038            value = value[0]
1039        return next((k for k, v in self.map if v == value), value)
1040
1041
1042class AxisLabelDescriptor(SimpleDescriptor):
1043    """Container for axis label data.
1044
1045    Analogue of OpenType's STAT data for a single axis (formats 1, 2 and 3).
1046    All values are user values.
1047    See: `OTSpec STAT Axis value table, format 1, 2, 3 <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-table-format-1>`_
1048
1049    The STAT format of the Axis value depends on which field are filled-in,
1050    see :meth:`getFormat`
1051
1052    .. versionadded:: 5.0
1053    """
1054
1055    flavor = "label"
1056    _attrs = ('userMinimum', 'userValue', 'userMaximum', 'name', 'elidable', 'olderSibling', 'linkedUserValue', 'labelNames')
1057
1058    def __init__(
1059        self,
1060        *,
1061        name,
1062        userValue,
1063        userMinimum=None,
1064        userMaximum=None,
1065        elidable=False,
1066        olderSibling=False,
1067        linkedUserValue=None,
1068        labelNames=None,
1069    ):
1070        self.userMinimum: Optional[float] = userMinimum
1071        """STAT field ``rangeMinValue`` (format 2)."""
1072        self.userValue: float = userValue
1073        """STAT field ``value`` (format 1, 3) or ``nominalValue`` (format 2)."""
1074        self.userMaximum: Optional[float] = userMaximum
1075        """STAT field ``rangeMaxValue`` (format 2)."""
1076        self.name: str = name
1077        """Label for this axis location, STAT field ``valueNameID``."""
1078        self.elidable: bool = elidable
1079        """STAT flag ``ELIDABLE_AXIS_VALUE_NAME``.
1080
1081        See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_
1082        """
1083        self.olderSibling: bool = olderSibling
1084        """STAT flag ``OLDER_SIBLING_FONT_ATTRIBUTE``.
1085
1086        See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_
1087        """
1088        self.linkedUserValue: Optional[float] = linkedUserValue
1089        """STAT field ``linkedValue`` (format 3)."""
1090        self.labelNames: MutableMapping[str, str] = labelNames or {}
1091        """User-facing translations of this location's label. Keyed by
1092        ``xml:lang`` code.
1093        """
1094
1095    def getFormat(self) -> int:
1096        """Determine which format of STAT Axis value to use to encode this label.
1097
1098        ===========  =========  ===========  ===========  ===============
1099        STAT Format  userValue  userMinimum  userMaximum  linkedUserValue
1100        ===========  =========  ===========  ===========  ===============
1101        1            ✅          ❌            ❌            ❌
1102        2            ✅          ✅            ✅            ❌
1103        3            ✅          ❌            ❌            ✅
1104        ===========  =========  ===========  ===========  ===============
1105        """
1106        if self.linkedUserValue is not None:
1107            return 3
1108        if self.userMinimum is not None or self.userMaximum is not None:
1109            return 2
1110        return 1
1111
1112    @property
1113    def defaultName(self) -> str:
1114        """Return the English name from :attr:`labelNames` or the :attr:`name`."""
1115        return self.labelNames.get("en") or self.name
1116
1117
1118class LocationLabelDescriptor(SimpleDescriptor):
1119    """Container for location label data.
1120
1121    Analogue of OpenType's STAT data for a free-floating location (format 4).
1122    All values are user values.
1123
1124    See: `OTSpec STAT Axis value table, format 4 <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-table-format-4>`_
1125
1126    .. versionadded:: 5.0
1127    """
1128
1129    flavor = "label"
1130    _attrs = ('name', 'elidable', 'olderSibling', 'userLocation', 'labelNames')
1131
1132    def __init__(
1133        self,
1134        *,
1135        name,
1136        userLocation,
1137        elidable=False,
1138        olderSibling=False,
1139        labelNames=None,
1140    ):
1141        self.name: str = name
1142        """Label for this named location, STAT field ``valueNameID``."""
1143        self.userLocation: SimpleLocationDict = userLocation or {}
1144        """Location in user coordinates along each axis.
1145
1146        If an axis is not mentioned, it is assumed to be at its default location.
1147
1148        .. seealso:: This may be only part of the full location. See:
1149           :meth:`getFullUserLocation`
1150        """
1151        self.elidable: bool = elidable
1152        """STAT flag ``ELIDABLE_AXIS_VALUE_NAME``.
1153
1154        See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_
1155        """
1156        self.olderSibling: bool = olderSibling
1157        """STAT flag ``OLDER_SIBLING_FONT_ATTRIBUTE``.
1158
1159        See: `OTSpec STAT Flags <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#flags>`_
1160        """
1161        self.labelNames: Dict[str, str] = labelNames or {}
1162        """User-facing translations of this location's label. Keyed by
1163        xml:lang code.
1164        """
1165
1166    @property
1167    def defaultName(self) -> str:
1168        """Return the English name from :attr:`labelNames` or the :attr:`name`."""
1169        return self.labelNames.get("en") or self.name
1170
1171    def getFullUserLocation(self, doc: 'DesignSpaceDocument') -> SimpleLocationDict:
1172        """Get the complete user location of this label, by combining data
1173        from the explicit user location and default axis values.
1174
1175        .. versionadded:: 5.0
1176        """
1177        return {
1178            axis.name: self.userLocation.get(axis.name, axis.default)
1179            for axis in doc.axes
1180        }
1181
1182
1183class VariableFontDescriptor(SimpleDescriptor):
1184    """Container for variable fonts, sub-spaces of the Designspace.
1185
1186    Use-cases:
1187
1188    - From a single DesignSpace with discrete axes, define 1 variable font
1189      per value on the discrete axes. Before version 5, you would have needed
1190      1 DesignSpace per such variable font, and a lot of data duplication.
1191    - From a big variable font with many axes, define subsets of that variable
1192      font that only include some axes and freeze other axes at a given location.
1193
1194    .. versionadded:: 5.0
1195    """
1196
1197    flavor = "variable-font"
1198    _attrs = ('filename', 'axisSubsets', 'lib')
1199
1200    filename = posixpath_property("_filename")
1201
1202    def __init__(self, *, name, filename=None, axisSubsets=None, lib=None):
1203        self.name: str = name
1204        """string, required. Name of this variable to identify it during the
1205        build process and from other parts of the document, and also as a
1206        filename in case the filename property is empty.
1207
1208        VarLib.
1209        """
1210        self.filename: str = filename
1211        """string, optional. Relative path to the variable font file, **as it is
1212        in the document**. The file may or may not exist.
1213
1214        If not specified, the :attr:`name` will be used as a basename for the file.
1215        """
1216        self.axisSubsets: List[Union[RangeAxisSubsetDescriptor, ValueAxisSubsetDescriptor]] = axisSubsets or []
1217        """Axis subsets to include in this variable font.
1218
1219        If an axis is not mentioned, assume that we only want the default
1220        location of that axis (same as a :class:`ValueAxisSubsetDescriptor`).
1221        """
1222        self.lib: MutableMapping[str, Any] = lib or {}
1223        """Custom data associated with this variable font."""
1224
1225
1226class RangeAxisSubsetDescriptor(SimpleDescriptor):
1227    """Subset of a continuous axis to include in a variable font.
1228
1229    .. versionadded:: 5.0
1230    """
1231    flavor = "axis-subset"
1232    _attrs = ('name', 'userMinimum', 'userDefault', 'userMaximum')
1233
1234    def __init__(self, *, name, userMinimum=-math.inf, userDefault=None, userMaximum=math.inf):
1235        self.name: str = name
1236        """Name of the :class:`AxisDescriptor` to subset."""
1237        self.userMinimum: float = userMinimum
1238        """New minimum value of the axis in the target variable font.
1239        If not specified, assume the same minimum value as the full axis.
1240        (default = ``-math.inf``)
1241        """
1242        self.userDefault: Optional[float] = userDefault
1243        """New default value of the axis in the target variable font.
1244        If not specified, assume the same default value as the full axis.
1245        (default = ``None``)
1246        """
1247        self.userMaximum: float = userMaximum
1248        """New maximum value of the axis in the target variable font.
1249        If not specified, assume the same maximum value as the full axis.
1250        (default = ``math.inf``)
1251        """
1252
1253
1254class ValueAxisSubsetDescriptor(SimpleDescriptor):
1255    """Single value of a discrete or continuous axis to use in a variable font.
1256
1257    .. versionadded:: 5.0
1258    """
1259    flavor = "axis-subset"
1260    _attrs = ('name', 'userValue')
1261
1262    def __init__(self, *, name, userValue):
1263        self.name: str = name
1264        """Name of the :class:`AxisDescriptor` or :class:`DiscreteAxisDescriptor`
1265        to "snapshot" or "freeze".
1266        """
1267        self.userValue: float = userValue
1268        """Value in user coordinates at which to freeze the given axis."""
1269
1270
1271class BaseDocWriter(object):
1272    _whiteSpace = "    "
1273    axisDescriptorClass = AxisDescriptor
1274    discreteAxisDescriptorClass = DiscreteAxisDescriptor
1275    axisLabelDescriptorClass = AxisLabelDescriptor
1276    locationLabelDescriptorClass = LocationLabelDescriptor
1277    ruleDescriptorClass = RuleDescriptor
1278    sourceDescriptorClass = SourceDescriptor
1279    variableFontDescriptorClass = VariableFontDescriptor
1280    valueAxisSubsetDescriptorClass = ValueAxisSubsetDescriptor
1281    rangeAxisSubsetDescriptorClass = RangeAxisSubsetDescriptor
1282    instanceDescriptorClass = InstanceDescriptor
1283
1284    @classmethod
1285    def getAxisDecriptor(cls):
1286        return cls.axisDescriptorClass()
1287
1288    @classmethod
1289    def getSourceDescriptor(cls):
1290        return cls.sourceDescriptorClass()
1291
1292    @classmethod
1293    def getInstanceDescriptor(cls):
1294        return cls.instanceDescriptorClass()
1295
1296    @classmethod
1297    def getRuleDescriptor(cls):
1298        return cls.ruleDescriptorClass()
1299
1300    def __init__(self, documentPath, documentObject: DesignSpaceDocument):
1301        self.path = documentPath
1302        self.documentObject = documentObject
1303        self.effectiveFormatTuple = self._getEffectiveFormatTuple()
1304        self.root = ET.Element("designspace")
1305
1306    def write(self, pretty=True, encoding="UTF-8", xml_declaration=True):
1307        self.root.attrib['format'] = ".".join(str(i) for i in self.effectiveFormatTuple)
1308
1309        if self.documentObject.axes or self.documentObject.elidedFallbackName is not None:
1310            axesElement = ET.Element("axes")
1311            if self.documentObject.elidedFallbackName is not None:
1312                axesElement.attrib['elidedfallbackname'] = self.documentObject.elidedFallbackName
1313            self.root.append(axesElement)
1314        for axisObject in self.documentObject.axes:
1315            self._addAxis(axisObject)
1316
1317        if self.documentObject.locationLabels:
1318            labelsElement = ET.Element("labels")
1319            for labelObject in self.documentObject.locationLabels:
1320                self._addLocationLabel(labelsElement, labelObject)
1321            self.root.append(labelsElement)
1322
1323        if self.documentObject.rules:
1324            if getattr(self.documentObject, "rulesProcessingLast", False):
1325                attributes = {"processing": "last"}
1326            else:
1327                attributes = {}
1328            self.root.append(ET.Element("rules", attributes))
1329        for ruleObject in self.documentObject.rules:
1330            self._addRule(ruleObject)
1331
1332        if self.documentObject.sources:
1333            self.root.append(ET.Element("sources"))
1334        for sourceObject in self.documentObject.sources:
1335            self._addSource(sourceObject)
1336
1337        if self.documentObject.variableFonts:
1338            variableFontsElement = ET.Element("variable-fonts")
1339            for variableFont in self.documentObject.variableFonts:
1340                self._addVariableFont(variableFontsElement, variableFont)
1341            self.root.append(variableFontsElement)
1342
1343        if self.documentObject.instances:
1344            self.root.append(ET.Element("instances"))
1345        for instanceObject in self.documentObject.instances:
1346            self._addInstance(instanceObject)
1347
1348        if self.documentObject.lib:
1349            self._addLib(self.root, self.documentObject.lib, 2)
1350
1351        tree = ET.ElementTree(self.root)
1352        tree.write(
1353            self.path,
1354            encoding=encoding,
1355            method='xml',
1356            xml_declaration=xml_declaration,
1357            pretty_print=pretty,
1358        )
1359
1360    def _getEffectiveFormatTuple(self):
1361        """Try to use the version specified in the document, or a sufficiently
1362        recent version to be able to encode what the document contains.
1363        """
1364        minVersion = self.documentObject.formatTuple
1365        if (
1366            any(
1367                hasattr(axis, 'values') or
1368                axis.axisOrdering is not None or
1369                axis.axisLabels
1370                for axis in self.documentObject.axes
1371            ) or
1372            self.documentObject.locationLabels or
1373            any(
1374                source.localisedFamilyName
1375                for source in self.documentObject.sources
1376            ) or
1377            self.documentObject.variableFonts or
1378            any(
1379                instance.locationLabel or
1380                instance.userLocation
1381                for instance in self.documentObject.instances
1382            )
1383        ):
1384            if minVersion < (5, 0):
1385                minVersion = (5, 0)
1386        return minVersion
1387
1388    def _makeLocationElement(self, locationObject, name=None):
1389        """ Convert Location dict to a locationElement."""
1390        locElement = ET.Element("location")
1391        if name is not None:
1392            locElement.attrib['name'] = name
1393        validatedLocation = self.documentObject.newDefaultLocation()
1394        for axisName, axisValue in locationObject.items():
1395            if axisName in validatedLocation:
1396                # only accept values we know
1397                validatedLocation[axisName] = axisValue
1398        for dimensionName, dimensionValue in validatedLocation.items():
1399            dimElement = ET.Element('dimension')
1400            dimElement.attrib['name'] = dimensionName
1401            if type(dimensionValue) == tuple:
1402                dimElement.attrib['xvalue'] = self.intOrFloat(dimensionValue[0])
1403                dimElement.attrib['yvalue'] = self.intOrFloat(dimensionValue[1])
1404            else:
1405                dimElement.attrib['xvalue'] = self.intOrFloat(dimensionValue)
1406            locElement.append(dimElement)
1407        return locElement, validatedLocation
1408
1409    def intOrFloat(self, num):
1410        if int(num) == num:
1411            return "%d" % num
1412        return ("%f" % num).rstrip('0').rstrip('.')
1413
1414    def _addRule(self, ruleObject):
1415        # if none of the conditions have minimum or maximum values, do not add the rule.
1416        ruleElement = ET.Element('rule')
1417        if ruleObject.name is not None:
1418            ruleElement.attrib['name'] = ruleObject.name
1419        for conditions in ruleObject.conditionSets:
1420            conditionsetElement = ET.Element('conditionset')
1421            for cond in conditions:
1422                if cond.get('minimum') is None and cond.get('maximum') is None:
1423                    # neither is defined, don't add this condition
1424                    continue
1425                conditionElement = ET.Element('condition')
1426                conditionElement.attrib['name'] = cond.get('name')
1427                if cond.get('minimum') is not None:
1428                    conditionElement.attrib['minimum'] = self.intOrFloat(cond.get('minimum'))
1429                if cond.get('maximum') is not None:
1430                    conditionElement.attrib['maximum'] = self.intOrFloat(cond.get('maximum'))
1431                conditionsetElement.append(conditionElement)
1432            if len(conditionsetElement):
1433                ruleElement.append(conditionsetElement)
1434        for sub in ruleObject.subs:
1435            subElement = ET.Element('sub')
1436            subElement.attrib['name'] = sub[0]
1437            subElement.attrib['with'] = sub[1]
1438            ruleElement.append(subElement)
1439        if len(ruleElement):
1440            self.root.findall('.rules')[0].append(ruleElement)
1441
1442    def _addAxis(self, axisObject):
1443        axisElement = ET.Element('axis')
1444        axisElement.attrib['tag'] = axisObject.tag
1445        axisElement.attrib['name'] = axisObject.name
1446        self._addLabelNames(axisElement, axisObject.labelNames)
1447        if axisObject.map:
1448            for inputValue, outputValue in axisObject.map:
1449                mapElement = ET.Element('map')
1450                mapElement.attrib['input'] = self.intOrFloat(inputValue)
1451                mapElement.attrib['output'] = self.intOrFloat(outputValue)
1452                axisElement.append(mapElement)
1453        if axisObject.axisOrdering or axisObject.axisLabels:
1454            labelsElement = ET.Element('labels')
1455            if axisObject.axisOrdering is not None:
1456                labelsElement.attrib['ordering'] = str(axisObject.axisOrdering)
1457            for label in axisObject.axisLabels:
1458                self._addAxisLabel(labelsElement, label)
1459            axisElement.append(labelsElement)
1460        if hasattr(axisObject, "minimum"):
1461            axisElement.attrib['minimum'] = self.intOrFloat(axisObject.minimum)
1462            axisElement.attrib['maximum'] = self.intOrFloat(axisObject.maximum)
1463        elif hasattr(axisObject, "values"):
1464            axisElement.attrib['values'] = " ".join(self.intOrFloat(v) for v in axisObject.values)
1465        axisElement.attrib['default'] = self.intOrFloat(axisObject.default)
1466        if axisObject.hidden:
1467            axisElement.attrib['hidden'] = "1"
1468        self.root.findall('.axes')[0].append(axisElement)
1469
1470    def _addAxisLabel(self, axisElement: ET.Element, label: AxisLabelDescriptor) -> None:
1471        labelElement = ET.Element('label')
1472        labelElement.attrib['uservalue'] = self.intOrFloat(label.userValue)
1473        if label.userMinimum is not None:
1474            labelElement.attrib['userminimum'] = self.intOrFloat(label.userMinimum)
1475        if label.userMaximum is not None:
1476            labelElement.attrib['usermaximum'] = self.intOrFloat(label.userMaximum)
1477        labelElement.attrib['name'] = label.name
1478        if label.elidable:
1479            labelElement.attrib['elidable'] = "true"
1480        if label.olderSibling:
1481            labelElement.attrib['oldersibling'] = "true"
1482        if label.linkedUserValue is not None:
1483            labelElement.attrib['linkeduservalue'] = self.intOrFloat(label.linkedUserValue)
1484        self._addLabelNames(labelElement, label.labelNames)
1485        axisElement.append(labelElement)
1486
1487    def _addLabelNames(self, parentElement, labelNames):
1488        for languageCode, labelName in sorted(labelNames.items()):
1489            languageElement = ET.Element('labelname')
1490            languageElement.attrib[XML_LANG] = languageCode
1491            languageElement.text = labelName
1492            parentElement.append(languageElement)
1493
1494    def _addLocationLabel(self, parentElement: ET.Element, label: LocationLabelDescriptor) -> None:
1495        labelElement = ET.Element('label')
1496        labelElement.attrib['name'] = label.name
1497        if label.elidable:
1498            labelElement.attrib['elidable'] = "true"
1499        if label.olderSibling:
1500            labelElement.attrib['oldersibling'] = "true"
1501        self._addLabelNames(labelElement, label.labelNames)
1502        self._addLocationElement(labelElement, userLocation=label.userLocation)
1503        parentElement.append(labelElement)
1504
1505    def _addLocationElement(
1506        self,
1507        parentElement,
1508        *,
1509        designLocation: AnisotropicLocationDict = None,
1510        userLocation: SimpleLocationDict = None
1511    ):
1512        locElement = ET.Element("location")
1513        for axis in self.documentObject.axes:
1514            if designLocation is not None and axis.name in designLocation:
1515                dimElement = ET.Element('dimension')
1516                dimElement.attrib['name'] = axis.name
1517                value = designLocation[axis.name]
1518                if isinstance(value, tuple):
1519                    dimElement.attrib['xvalue'] = self.intOrFloat(value[0])
1520                    dimElement.attrib['yvalue'] = self.intOrFloat(value[1])
1521                else:
1522                    dimElement.attrib['xvalue'] = self.intOrFloat(value)
1523                locElement.append(dimElement)
1524            elif userLocation is not None and axis.name in userLocation:
1525                dimElement = ET.Element('dimension')
1526                dimElement.attrib['name'] = axis.name
1527                value = userLocation[axis.name]
1528                dimElement.attrib['uservalue'] = self.intOrFloat(value)
1529                locElement.append(dimElement)
1530        if len(locElement) > 0:
1531            parentElement.append(locElement)
1532
1533    def _addInstance(self, instanceObject):
1534        instanceElement = ET.Element('instance')
1535        if instanceObject.name is not None:
1536            instanceElement.attrib['name'] = instanceObject.name
1537        if instanceObject.locationLabel is not None:
1538            instanceElement.attrib['location'] = instanceObject.locationLabel
1539        if instanceObject.familyName is not None:
1540            instanceElement.attrib['familyname'] = instanceObject.familyName
1541        if instanceObject.styleName is not None:
1542            instanceElement.attrib['stylename'] = instanceObject.styleName
1543        # add localisations
1544        if instanceObject.localisedStyleName:
1545            languageCodes = list(instanceObject.localisedStyleName.keys())
1546            languageCodes.sort()
1547            for code in languageCodes:
1548                if code == "en":
1549                    continue  # already stored in the element attribute
1550                localisedStyleNameElement = ET.Element('stylename')
1551                localisedStyleNameElement.attrib[XML_LANG] = code
1552                localisedStyleNameElement.text = instanceObject.getStyleName(code)
1553                instanceElement.append(localisedStyleNameElement)
1554        if instanceObject.localisedFamilyName:
1555            languageCodes = list(instanceObject.localisedFamilyName.keys())
1556            languageCodes.sort()
1557            for code in languageCodes:
1558                if code == "en":
1559                    continue  # already stored in the element attribute
1560                localisedFamilyNameElement = ET.Element('familyname')
1561                localisedFamilyNameElement.attrib[XML_LANG] = code
1562                localisedFamilyNameElement.text = instanceObject.getFamilyName(code)
1563                instanceElement.append(localisedFamilyNameElement)
1564        if instanceObject.localisedStyleMapStyleName:
1565            languageCodes = list(instanceObject.localisedStyleMapStyleName.keys())
1566            languageCodes.sort()
1567            for code in languageCodes:
1568                if code == "en":
1569                    continue
1570                localisedStyleMapStyleNameElement = ET.Element('stylemapstylename')
1571                localisedStyleMapStyleNameElement.attrib[XML_LANG] = code
1572                localisedStyleMapStyleNameElement.text = instanceObject.getStyleMapStyleName(code)
1573                instanceElement.append(localisedStyleMapStyleNameElement)
1574        if instanceObject.localisedStyleMapFamilyName:
1575            languageCodes = list(instanceObject.localisedStyleMapFamilyName.keys())
1576            languageCodes.sort()
1577            for code in languageCodes:
1578                if code == "en":
1579                    continue
1580                localisedStyleMapFamilyNameElement = ET.Element('stylemapfamilyname')
1581                localisedStyleMapFamilyNameElement.attrib[XML_LANG] = code
1582                localisedStyleMapFamilyNameElement.text = instanceObject.getStyleMapFamilyName(code)
1583                instanceElement.append(localisedStyleMapFamilyNameElement)
1584
1585        if self.effectiveFormatTuple >= (5, 0):
1586            if instanceObject.locationLabel is None:
1587                self._addLocationElement(
1588                    instanceElement,
1589                    designLocation=instanceObject.designLocation,
1590                    userLocation=instanceObject.userLocation
1591                )
1592        else:
1593            # Pre-version 5.0 code was validating and filling in the location
1594            # dict while writing it out, as preserved below.
1595            if instanceObject.location is not None:
1596                locationElement, instanceObject.location = self._makeLocationElement(instanceObject.location)
1597                instanceElement.append(locationElement)
1598        if instanceObject.filename is not None:
1599            instanceElement.attrib['filename'] = instanceObject.filename
1600        if instanceObject.postScriptFontName is not None:
1601            instanceElement.attrib['postscriptfontname'] = instanceObject.postScriptFontName
1602        if instanceObject.styleMapFamilyName is not None:
1603            instanceElement.attrib['stylemapfamilyname'] = instanceObject.styleMapFamilyName
1604        if instanceObject.styleMapStyleName is not None:
1605            instanceElement.attrib['stylemapstylename'] = instanceObject.styleMapStyleName
1606        if self.effectiveFormatTuple < (5, 0):
1607            # Deprecated members as of version 5.0
1608            if instanceObject.glyphs:
1609                if instanceElement.findall('.glyphs') == []:
1610                    glyphsElement = ET.Element('glyphs')
1611                    instanceElement.append(glyphsElement)
1612                glyphsElement = instanceElement.findall('.glyphs')[0]
1613                for glyphName, data in sorted(instanceObject.glyphs.items()):
1614                    glyphElement = self._writeGlyphElement(instanceElement, instanceObject, glyphName, data)
1615                    glyphsElement.append(glyphElement)
1616            if instanceObject.kerning:
1617                kerningElement = ET.Element('kerning')
1618                instanceElement.append(kerningElement)
1619            if instanceObject.info:
1620                infoElement = ET.Element('info')
1621                instanceElement.append(infoElement)
1622        self._addLib(instanceElement, instanceObject.lib, 4)
1623        self.root.findall('.instances')[0].append(instanceElement)
1624
1625    def _addSource(self, sourceObject):
1626        sourceElement = ET.Element("source")
1627        if sourceObject.filename is not None:
1628            sourceElement.attrib['filename'] = sourceObject.filename
1629        if sourceObject.name is not None:
1630            if sourceObject.name.find("temp_master") != 0:
1631                # do not save temporary source names
1632                sourceElement.attrib['name'] = sourceObject.name
1633        if sourceObject.familyName is not None:
1634            sourceElement.attrib['familyname'] = sourceObject.familyName
1635        if sourceObject.styleName is not None:
1636            sourceElement.attrib['stylename'] = sourceObject.styleName
1637        if sourceObject.layerName is not None:
1638            sourceElement.attrib['layer'] = sourceObject.layerName
1639        if sourceObject.localisedFamilyName:
1640            languageCodes = list(sourceObject.localisedFamilyName.keys())
1641            languageCodes.sort()
1642            for code in languageCodes:
1643                if code == "en":
1644                    continue  # already stored in the element attribute
1645                localisedFamilyNameElement = ET.Element('familyname')
1646                localisedFamilyNameElement.attrib[XML_LANG] = code
1647                localisedFamilyNameElement.text = sourceObject.getFamilyName(code)
1648                sourceElement.append(localisedFamilyNameElement)
1649        if sourceObject.copyLib:
1650            libElement = ET.Element('lib')
1651            libElement.attrib['copy'] = "1"
1652            sourceElement.append(libElement)
1653        if sourceObject.copyGroups:
1654            groupsElement = ET.Element('groups')
1655            groupsElement.attrib['copy'] = "1"
1656            sourceElement.append(groupsElement)
1657        if sourceObject.copyFeatures:
1658            featuresElement = ET.Element('features')
1659            featuresElement.attrib['copy'] = "1"
1660            sourceElement.append(featuresElement)
1661        if sourceObject.copyInfo or sourceObject.muteInfo:
1662            infoElement = ET.Element('info')
1663            if sourceObject.copyInfo:
1664                infoElement.attrib['copy'] = "1"
1665            if sourceObject.muteInfo:
1666                infoElement.attrib['mute'] = "1"
1667            sourceElement.append(infoElement)
1668        if sourceObject.muteKerning:
1669            kerningElement = ET.Element("kerning")
1670            kerningElement.attrib["mute"] = '1'
1671            sourceElement.append(kerningElement)
1672        if sourceObject.mutedGlyphNames:
1673            for name in sourceObject.mutedGlyphNames:
1674                glyphElement = ET.Element("glyph")
1675                glyphElement.attrib["name"] = name
1676                glyphElement.attrib["mute"] = '1'
1677                sourceElement.append(glyphElement)
1678        if self.effectiveFormatTuple >= (5, 0):
1679            self._addLocationElement(sourceElement, designLocation=sourceObject.location)
1680        else:
1681            # Pre-version 5.0 code was validating and filling in the location
1682            # dict while writing it out, as preserved below.
1683            locationElement, sourceObject.location = self._makeLocationElement(sourceObject.location)
1684            sourceElement.append(locationElement)
1685        self.root.findall('.sources')[0].append(sourceElement)
1686
1687    def _addVariableFont(self, parentElement: ET.Element, vf: VariableFontDescriptor) -> None:
1688        vfElement = ET.Element('variable-font')
1689        vfElement.attrib['name'] = vf.name
1690        if vf.filename is not None:
1691            vfElement.attrib['filename'] = vf.filename
1692        if vf.axisSubsets:
1693            subsetsElement = ET.Element('axis-subsets')
1694            for subset in vf.axisSubsets:
1695                subsetElement = ET.Element('axis-subset')
1696                subsetElement.attrib['name'] = subset.name
1697                # Mypy doesn't support narrowing union types via hasattr()
1698                # https://mypy.readthedocs.io/en/stable/type_narrowing.html
1699                # TODO(Python 3.10): use TypeGuard
1700                if hasattr(subset, "userMinimum"):
1701                    subset = cast(RangeAxisSubsetDescriptor, subset)
1702                    if subset.userMinimum != -math.inf:
1703                        subsetElement.attrib['userminimum'] = self.intOrFloat(subset.userMinimum)
1704                    if subset.userMaximum != math.inf:
1705                        subsetElement.attrib['usermaximum'] = self.intOrFloat(subset.userMaximum)
1706                    if subset.userDefault is not None:
1707                        subsetElement.attrib['userdefault'] = self.intOrFloat(subset.userDefault)
1708                elif hasattr(subset, "userValue"):
1709                    subset = cast(ValueAxisSubsetDescriptor, subset)
1710                    subsetElement.attrib['uservalue'] = self.intOrFloat(subset.userValue)
1711                subsetsElement.append(subsetElement)
1712            vfElement.append(subsetsElement)
1713        self._addLib(vfElement, vf.lib, 4)
1714        parentElement.append(vfElement)
1715
1716    def _addLib(self, parentElement: ET.Element, data: Any, indent_level: int) -> None:
1717        if not data:
1718            return
1719        libElement = ET.Element('lib')
1720        libElement.append(plistlib.totree(data, indent_level=indent_level))
1721        parentElement.append(libElement)
1722
1723    def _writeGlyphElement(self, instanceElement, instanceObject, glyphName, data):
1724        glyphElement = ET.Element('glyph')
1725        if data.get('mute'):
1726            glyphElement.attrib['mute'] = "1"
1727        if data.get('unicodes') is not None:
1728            glyphElement.attrib['unicode'] = " ".join([hex(u) for u in data.get('unicodes')])
1729        if data.get('instanceLocation') is not None:
1730            locationElement, data['instanceLocation'] = self._makeLocationElement(data.get('instanceLocation'))
1731            glyphElement.append(locationElement)
1732        if glyphName is not None:
1733            glyphElement.attrib['name'] = glyphName
1734        if data.get('note') is not None:
1735            noteElement = ET.Element('note')
1736            noteElement.text = data.get('note')
1737            glyphElement.append(noteElement)
1738        if data.get('masters') is not None:
1739            mastersElement = ET.Element("masters")
1740            for m in data.get('masters'):
1741                masterElement = ET.Element("master")
1742                if m.get('glyphName') is not None:
1743                    masterElement.attrib['glyphname'] = m.get('glyphName')
1744                if m.get('font') is not None:
1745                    masterElement.attrib['source'] = m.get('font')
1746                if m.get('location') is not None:
1747                    locationElement, m['location'] = self._makeLocationElement(m.get('location'))
1748                    masterElement.append(locationElement)
1749                mastersElement.append(masterElement)
1750            glyphElement.append(mastersElement)
1751        return glyphElement
1752
1753
1754class BaseDocReader(LogMixin):
1755    axisDescriptorClass = AxisDescriptor
1756    discreteAxisDescriptorClass = DiscreteAxisDescriptor
1757    axisLabelDescriptorClass = AxisLabelDescriptor
1758    locationLabelDescriptorClass = LocationLabelDescriptor
1759    ruleDescriptorClass = RuleDescriptor
1760    sourceDescriptorClass = SourceDescriptor
1761    variableFontsDescriptorClass = VariableFontDescriptor
1762    valueAxisSubsetDescriptorClass = ValueAxisSubsetDescriptor
1763    rangeAxisSubsetDescriptorClass = RangeAxisSubsetDescriptor
1764    instanceDescriptorClass = InstanceDescriptor
1765
1766    def __init__(self, documentPath, documentObject):
1767        self.path = documentPath
1768        self.documentObject = documentObject
1769        tree = ET.parse(self.path)
1770        self.root = tree.getroot()
1771        self.documentObject.formatVersion = self.root.attrib.get("format", "3.0")
1772        self._axes = []
1773        self.rules = []
1774        self.sources = []
1775        self.instances = []
1776        self.axisDefaults = {}
1777        self._strictAxisNames = True
1778
1779    @classmethod
1780    def fromstring(cls, string, documentObject):
1781        f = BytesIO(tobytes(string, encoding="utf-8"))
1782        self = cls(f, documentObject)
1783        self.path = None
1784        return self
1785
1786    def read(self):
1787        self.readAxes()
1788        self.readLabels()
1789        self.readRules()
1790        self.readVariableFonts()
1791        self.readSources()
1792        self.readInstances()
1793        self.readLib()
1794
1795    def readRules(self):
1796        # we also need to read any conditions that are outside of a condition set.
1797        rules = []
1798        rulesElement = self.root.find(".rules")
1799        if rulesElement is not None:
1800            processingValue = rulesElement.attrib.get("processing", "first")
1801            if processingValue not in {"first", "last"}:
1802                raise DesignSpaceDocumentError(
1803                    "<rules> processing attribute value is not valid: %r, "
1804                    "expected 'first' or 'last'" % processingValue)
1805            self.documentObject.rulesProcessingLast = processingValue == "last"
1806        for ruleElement in self.root.findall(".rules/rule"):
1807            ruleObject = self.ruleDescriptorClass()
1808            ruleName = ruleObject.name = ruleElement.attrib.get("name")
1809            # read any stray conditions outside a condition set
1810            externalConditions = self._readConditionElements(
1811                ruleElement,
1812                ruleName,
1813            )
1814            if externalConditions:
1815                ruleObject.conditionSets.append(externalConditions)
1816                self.log.info(
1817                    "Found stray rule conditions outside a conditionset. "
1818                    "Wrapped them in a new conditionset."
1819                )
1820            # read the conditionsets
1821            for conditionSetElement in ruleElement.findall('.conditionset'):
1822                conditionSet = self._readConditionElements(
1823                    conditionSetElement,
1824                    ruleName,
1825                )
1826                if conditionSet is not None:
1827                    ruleObject.conditionSets.append(conditionSet)
1828            for subElement in ruleElement.findall('.sub'):
1829                a = subElement.attrib['name']
1830                b = subElement.attrib['with']
1831                ruleObject.subs.append((a, b))
1832            rules.append(ruleObject)
1833        self.documentObject.rules = rules
1834
1835    def _readConditionElements(self, parentElement, ruleName=None):
1836        cds = []
1837        for conditionElement in parentElement.findall('.condition'):
1838            cd = {}
1839            cdMin = conditionElement.attrib.get("minimum")
1840            if cdMin is not None:
1841                cd['minimum'] = float(cdMin)
1842            else:
1843                # will allow these to be None, assume axis.minimum
1844                cd['minimum'] = None
1845            cdMax = conditionElement.attrib.get("maximum")
1846            if cdMax is not None:
1847                cd['maximum'] = float(cdMax)
1848            else:
1849                # will allow these to be None, assume axis.maximum
1850                cd['maximum'] = None
1851            cd['name'] = conditionElement.attrib.get("name")
1852            # # test for things
1853            if cd.get('minimum') is None and cd.get('maximum') is None:
1854                raise DesignSpaceDocumentError(
1855                    "condition missing required minimum or maximum in rule" +
1856                    (" '%s'" % ruleName if ruleName is not None else ""))
1857            cds.append(cd)
1858        return cds
1859
1860    def readAxes(self):
1861        # read the axes elements, including the warp map.
1862        axesElement = self.root.find(".axes")
1863        if axesElement is not None and 'elidedfallbackname' in axesElement.attrib:
1864            self.documentObject.elidedFallbackName = axesElement.attrib['elidedfallbackname']
1865        axisElements = self.root.findall(".axes/axis")
1866        if not axisElements:
1867            return
1868        for axisElement in axisElements:
1869            if self.documentObject.formatTuple >= (5, 0) and "values" in axisElement.attrib:
1870                axisObject = self.discreteAxisDescriptorClass()
1871                axisObject.values = [float(s) for s in axisElement.attrib["values"].split(" ")]
1872            else:
1873                axisObject = self.axisDescriptorClass()
1874                axisObject.minimum = float(axisElement.attrib.get("minimum"))
1875                axisObject.maximum = float(axisElement.attrib.get("maximum"))
1876            axisObject.default = float(axisElement.attrib.get("default"))
1877            axisObject.name = axisElement.attrib.get("name")
1878            if axisElement.attrib.get('hidden', False):
1879                axisObject.hidden = True
1880            axisObject.tag = axisElement.attrib.get("tag")
1881            for mapElement in axisElement.findall('map'):
1882                a = float(mapElement.attrib['input'])
1883                b = float(mapElement.attrib['output'])
1884                axisObject.map.append((a, b))
1885            for labelNameElement in axisElement.findall('labelname'):
1886                # Note: elementtree reads the "xml:lang" attribute name as
1887                # '{http://www.w3.org/XML/1998/namespace}lang'
1888                for key, lang in labelNameElement.items():
1889                    if key == XML_LANG:
1890                        axisObject.labelNames[lang] = tostr(labelNameElement.text)
1891            labelElement = axisElement.find(".labels")
1892            if labelElement is not None:
1893                if "ordering" in labelElement.attrib:
1894                    axisObject.axisOrdering = int(labelElement.attrib["ordering"])
1895                for label in labelElement.findall(".label"):
1896                    axisObject.axisLabels.append(self.readAxisLabel(label))
1897            self.documentObject.axes.append(axisObject)
1898            self.axisDefaults[axisObject.name] = axisObject.default
1899
1900    def readAxisLabel(self, element: ET.Element):
1901        xml_attrs = {'userminimum', 'uservalue', 'usermaximum', 'name', 'elidable', 'oldersibling', 'linkeduservalue'}
1902        unknown_attrs = set(element.attrib) - xml_attrs
1903        if unknown_attrs:
1904            raise DesignSpaceDocumentError(f"label element contains unknown attributes: {', '.join(unknown_attrs)}")
1905
1906        name = element.get("name")
1907        if name is None:
1908            raise DesignSpaceDocumentError("label element must have a name attribute.")
1909        valueStr = element.get("uservalue")
1910        if valueStr is None:
1911            raise DesignSpaceDocumentError("label element must have a uservalue attribute.")
1912        value = float(valueStr)
1913        minimumStr = element.get("userminimum")
1914        minimum = float(minimumStr) if minimumStr is not None else None
1915        maximumStr = element.get("usermaximum")
1916        maximum = float(maximumStr) if maximumStr is not None else None
1917        linkedValueStr = element.get("linkeduservalue")
1918        linkedValue = float(linkedValueStr) if linkedValueStr is not None else None
1919        elidable = True if element.get("elidable") == "true" else False
1920        olderSibling = True if element.get("oldersibling") == "true" else False
1921        labelNames = {
1922            lang: label_name.text or ""
1923            for label_name in element.findall("labelname")
1924            for attr, lang in label_name.items()
1925            if attr == XML_LANG
1926            # Note: elementtree reads the "xml:lang" attribute name as
1927            # '{http://www.w3.org/XML/1998/namespace}lang'
1928        }
1929        return self.axisLabelDescriptorClass(
1930            name=name,
1931            userValue=value,
1932            userMinimum=minimum,
1933            userMaximum=maximum,
1934            elidable=elidable,
1935            olderSibling=olderSibling,
1936            linkedUserValue=linkedValue,
1937            labelNames=labelNames,
1938        )
1939
1940    def readLabels(self):
1941        if self.documentObject.formatTuple < (5, 0):
1942            return
1943
1944        xml_attrs = {'name', 'elidable', 'oldersibling'}
1945        for labelElement in self.root.findall(".labels/label"):
1946            unknown_attrs = set(labelElement.attrib) - xml_attrs
1947            if unknown_attrs:
1948                raise DesignSpaceDocumentError(f"Label element contains unknown attributes: {', '.join(unknown_attrs)}")
1949
1950            name = labelElement.get("name")
1951            if name is None:
1952                raise DesignSpaceDocumentError("label element must have a name attribute.")
1953            designLocation, userLocation = self.locationFromElement(labelElement)
1954            if designLocation:
1955                raise DesignSpaceDocumentError(f'<label> element "{name}" must only have user locations (using uservalue="").')
1956            elidable = True if labelElement.get("elidable") == "true" else False
1957            olderSibling = True if labelElement.get("oldersibling") == "true" else False
1958            labelNames = {
1959                lang: label_name.text or ""
1960                for label_name in labelElement.findall("labelname")
1961                for attr, lang in label_name.items()
1962                if attr == XML_LANG
1963                # Note: elementtree reads the "xml:lang" attribute name as
1964                # '{http://www.w3.org/XML/1998/namespace}lang'
1965            }
1966            locationLabel = self.locationLabelDescriptorClass(
1967                name=name,
1968                userLocation=userLocation,
1969                elidable=elidable,
1970                olderSibling=olderSibling,
1971                labelNames=labelNames,
1972            )
1973            self.documentObject.locationLabels.append(locationLabel)
1974
1975    def readVariableFonts(self):
1976        if self.documentObject.formatTuple < (5, 0):
1977            return
1978
1979        xml_attrs = {'name', 'filename'}
1980        for variableFontElement in self.root.findall(".variable-fonts/variable-font"):
1981            unknown_attrs = set(variableFontElement.attrib) - xml_attrs
1982            if unknown_attrs:
1983                raise DesignSpaceDocumentError(f"variable-font element contains unknown attributes: {', '.join(unknown_attrs)}")
1984
1985            name = variableFontElement.get("name")
1986            if name is None:
1987                raise DesignSpaceDocumentError("variable-font element must have a name attribute.")
1988
1989            filename = variableFontElement.get("filename")
1990
1991            axisSubsetsElement = variableFontElement.find(".axis-subsets")
1992            if axisSubsetsElement is None:
1993                raise DesignSpaceDocumentError("variable-font element must contain an axis-subsets element.")
1994            axisSubsets = []
1995            for axisSubset in axisSubsetsElement.iterfind(".axis-subset"):
1996                axisSubsets.append(self.readAxisSubset(axisSubset))
1997
1998            lib = None
1999            libElement = variableFontElement.find(".lib")
2000            if libElement is not None:
2001                lib = plistlib.fromtree(libElement[0])
2002
2003            variableFont = self.variableFontsDescriptorClass(
2004                name=name,
2005                filename=filename,
2006                axisSubsets=axisSubsets,
2007                lib=lib,
2008            )
2009            self.documentObject.variableFonts.append(variableFont)
2010
2011    def readAxisSubset(self, element: ET.Element):
2012        if "uservalue" in element.attrib:
2013            xml_attrs = {'name', 'uservalue'}
2014            unknown_attrs = set(element.attrib) - xml_attrs
2015            if unknown_attrs:
2016                raise DesignSpaceDocumentError(f"axis-subset element contains unknown attributes: {', '.join(unknown_attrs)}")
2017
2018            name = element.get("name")
2019            if name is None:
2020                raise DesignSpaceDocumentError("axis-subset element must have a name attribute.")
2021            userValueStr = element.get("uservalue")
2022            if userValueStr is None:
2023                raise DesignSpaceDocumentError(
2024                    "The axis-subset element for a discrete subset must have a uservalue attribute."
2025                )
2026            userValue = float(userValueStr)
2027
2028            return self.valueAxisSubsetDescriptorClass(name=name, userValue=userValue)
2029        else:
2030            xml_attrs = {'name', 'userminimum', 'userdefault', 'usermaximum'}
2031            unknown_attrs = set(element.attrib) - xml_attrs
2032            if unknown_attrs:
2033                raise DesignSpaceDocumentError(f"axis-subset element contains unknown attributes: {', '.join(unknown_attrs)}")
2034
2035            name = element.get("name")
2036            if name is None:
2037                raise DesignSpaceDocumentError("axis-subset element must have a name attribute.")
2038
2039            userMinimum = element.get("userminimum")
2040            userDefault = element.get("userdefault")
2041            userMaximum = element.get("usermaximum")
2042            if userMinimum is not None and userDefault is not None and userMaximum is not None:
2043                return self.rangeAxisSubsetDescriptorClass(
2044                    name=name,
2045                    userMinimum=float(userMinimum),
2046                    userDefault=float(userDefault),
2047                    userMaximum=float(userMaximum),
2048                )
2049            if all(v is None for v in (userMinimum, userDefault, userMaximum)):
2050                return self.rangeAxisSubsetDescriptorClass(name=name)
2051
2052            raise DesignSpaceDocumentError(
2053                "axis-subset element must have min/max/default values or none at all."
2054            )
2055
2056
2057    def readSources(self):
2058        for sourceCount, sourceElement in enumerate(self.root.findall(".sources/source")):
2059            filename = sourceElement.attrib.get('filename')
2060            if filename is not None and self.path is not None:
2061                sourcePath = os.path.abspath(os.path.join(os.path.dirname(self.path), filename))
2062            else:
2063                sourcePath = None
2064            sourceName = sourceElement.attrib.get('name')
2065            if sourceName is None:
2066                # add a temporary source name
2067                sourceName = "temp_master.%d" % (sourceCount)
2068            sourceObject = self.sourceDescriptorClass()
2069            sourceObject.path = sourcePath        # absolute path to the ufo source
2070            sourceObject.filename = filename      # path as it is stored in the document
2071            sourceObject.name = sourceName
2072            familyName = sourceElement.attrib.get("familyname")
2073            if familyName is not None:
2074                sourceObject.familyName = familyName
2075            styleName = sourceElement.attrib.get("stylename")
2076            if styleName is not None:
2077                sourceObject.styleName = styleName
2078            for familyNameElement in sourceElement.findall('familyname'):
2079                for key, lang in familyNameElement.items():
2080                    if key == XML_LANG:
2081                        familyName = familyNameElement.text
2082                        sourceObject.setFamilyName(familyName, lang)
2083            designLocation, userLocation = self.locationFromElement(sourceElement)
2084            if userLocation:
2085                raise DesignSpaceDocumentError(f'<source> element "{sourceName}" must only have design locations (using xvalue="").')
2086            sourceObject.location = designLocation
2087            layerName = sourceElement.attrib.get('layer')
2088            if layerName is not None:
2089                sourceObject.layerName = layerName
2090            for libElement in sourceElement.findall('.lib'):
2091                if libElement.attrib.get('copy') == '1':
2092                    sourceObject.copyLib = True
2093            for groupsElement in sourceElement.findall('.groups'):
2094                if groupsElement.attrib.get('copy') == '1':
2095                    sourceObject.copyGroups = True
2096            for infoElement in sourceElement.findall(".info"):
2097                if infoElement.attrib.get('copy') == '1':
2098                    sourceObject.copyInfo = True
2099                if infoElement.attrib.get('mute') == '1':
2100                    sourceObject.muteInfo = True
2101            for featuresElement in sourceElement.findall(".features"):
2102                if featuresElement.attrib.get('copy') == '1':
2103                    sourceObject.copyFeatures = True
2104            for glyphElement in sourceElement.findall(".glyph"):
2105                glyphName = glyphElement.attrib.get('name')
2106                if glyphName is None:
2107                    continue
2108                if glyphElement.attrib.get('mute') == '1':
2109                    sourceObject.mutedGlyphNames.append(glyphName)
2110            for kerningElement in sourceElement.findall(".kerning"):
2111                if kerningElement.attrib.get('mute') == '1':
2112                    sourceObject.muteKerning = True
2113            self.documentObject.sources.append(sourceObject)
2114
2115    def locationFromElement(self, element):
2116        """Read a nested ``<location>`` element inside the given ``element``.
2117
2118        .. versionchanged:: 5.0
2119           Return a tuple of (designLocation, userLocation)
2120        """
2121        elementLocation = (None, None)
2122        for locationElement in element.findall('.location'):
2123            elementLocation = self.readLocationElement(locationElement)
2124            break
2125        return elementLocation
2126
2127    def readLocationElement(self, locationElement):
2128        """Read a ``<location>`` element.
2129
2130        .. versionchanged:: 5.0
2131           Return a tuple of (designLocation, userLocation)
2132        """
2133        if self._strictAxisNames and not self.documentObject.axes:
2134            raise DesignSpaceDocumentError("No axes defined")
2135        userLoc = {}
2136        designLoc = {}
2137        for dimensionElement in locationElement.findall(".dimension"):
2138            dimName = dimensionElement.attrib.get("name")
2139            if self._strictAxisNames and dimName not in self.axisDefaults:
2140                # In case the document contains no axis definitions,
2141                self.log.warning("Location with undefined axis: \"%s\".", dimName)
2142                continue
2143            userValue = xValue = yValue = None
2144            try:
2145                userValue = dimensionElement.attrib.get('uservalue')
2146                if userValue is not None:
2147                    userValue = float(userValue)
2148            except ValueError:
2149                self.log.warning("ValueError in readLocation userValue %3.3f", userValue)
2150            try:
2151                xValue = dimensionElement.attrib.get('xvalue')
2152                if xValue is not None:
2153                    xValue = float(xValue)
2154            except ValueError:
2155                self.log.warning("ValueError in readLocation xValue %3.3f", xValue)
2156            try:
2157                yValue = dimensionElement.attrib.get('yvalue')
2158                if yValue is not None:
2159                    yValue = float(yValue)
2160            except ValueError:
2161                self.log.warning("ValueError in readLocation yValue %3.3f", yValue)
2162            if userValue is None == xValue is None:
2163                raise DesignSpaceDocumentError(f'Exactly one of uservalue="" or xvalue="" must be provided for location dimension "{dimName}"')
2164            if yValue is not None:
2165                if xValue is None:
2166                    raise DesignSpaceDocumentError(f'Missing xvalue="" for the location dimension "{dimName}"" with yvalue="{yValue}"')
2167                designLoc[dimName] = (xValue, yValue)
2168            elif xValue is not None:
2169                designLoc[dimName] = xValue
2170            else:
2171                userLoc[dimName] = userValue
2172        return designLoc, userLoc
2173
2174    def readInstances(self, makeGlyphs=True, makeKerning=True, makeInfo=True):
2175        instanceElements = self.root.findall('.instances/instance')
2176        for instanceElement in instanceElements:
2177            self._readSingleInstanceElement(instanceElement, makeGlyphs=makeGlyphs, makeKerning=makeKerning, makeInfo=makeInfo)
2178
2179    def _readSingleInstanceElement(self, instanceElement, makeGlyphs=True, makeKerning=True, makeInfo=True):
2180        filename = instanceElement.attrib.get('filename')
2181        if filename is not None and self.documentObject.path is not None:
2182            instancePath = os.path.join(os.path.dirname(self.documentObject.path), filename)
2183        else:
2184            instancePath = None
2185        instanceObject = self.instanceDescriptorClass()
2186        instanceObject.path = instancePath    # absolute path to the instance
2187        instanceObject.filename = filename    # path as it is stored in the document
2188        name = instanceElement.attrib.get("name")
2189        if name is not None:
2190            instanceObject.name = name
2191        familyname = instanceElement.attrib.get('familyname')
2192        if familyname is not None:
2193            instanceObject.familyName = familyname
2194        stylename = instanceElement.attrib.get('stylename')
2195        if stylename is not None:
2196            instanceObject.styleName = stylename
2197        postScriptFontName = instanceElement.attrib.get('postscriptfontname')
2198        if postScriptFontName is not None:
2199            instanceObject.postScriptFontName = postScriptFontName
2200        styleMapFamilyName = instanceElement.attrib.get('stylemapfamilyname')
2201        if styleMapFamilyName is not None:
2202            instanceObject.styleMapFamilyName = styleMapFamilyName
2203        styleMapStyleName = instanceElement.attrib.get('stylemapstylename')
2204        if styleMapStyleName is not None:
2205            instanceObject.styleMapStyleName = styleMapStyleName
2206        # read localised names
2207        for styleNameElement in instanceElement.findall('stylename'):
2208            for key, lang in styleNameElement.items():
2209                if key == XML_LANG:
2210                    styleName = styleNameElement.text
2211                    instanceObject.setStyleName(styleName, lang)
2212        for familyNameElement in instanceElement.findall('familyname'):
2213            for key, lang in familyNameElement.items():
2214                if key == XML_LANG:
2215                    familyName = familyNameElement.text
2216                    instanceObject.setFamilyName(familyName, lang)
2217        for styleMapStyleNameElement in instanceElement.findall('stylemapstylename'):
2218            for key, lang in styleMapStyleNameElement.items():
2219                if key == XML_LANG:
2220                    styleMapStyleName = styleMapStyleNameElement.text
2221                    instanceObject.setStyleMapStyleName(styleMapStyleName, lang)
2222        for styleMapFamilyNameElement in instanceElement.findall('stylemapfamilyname'):
2223            for key, lang in styleMapFamilyNameElement.items():
2224                if key == XML_LANG:
2225                    styleMapFamilyName = styleMapFamilyNameElement.text
2226                    instanceObject.setStyleMapFamilyName(styleMapFamilyName, lang)
2227        designLocation, userLocation = self.locationFromElement(instanceElement)
2228        locationLabel = instanceElement.attrib.get('location')
2229        if (designLocation or userLocation) and locationLabel is not None:
2230            raise DesignSpaceDocumentError('instance element must have at most one of the location="..." attribute or the nested location element')
2231        instanceObject.locationLabel = locationLabel
2232        instanceObject.userLocation = userLocation or {}
2233        instanceObject.designLocation = designLocation or {}
2234        for glyphElement in instanceElement.findall('.glyphs/glyph'):
2235            self.readGlyphElement(glyphElement, instanceObject)
2236        for infoElement in instanceElement.findall("info"):
2237            self.readInfoElement(infoElement, instanceObject)
2238        for libElement in instanceElement.findall('lib'):
2239            self.readLibElement(libElement, instanceObject)
2240        self.documentObject.instances.append(instanceObject)
2241
2242    def readLibElement(self, libElement, instanceObject):
2243        """Read the lib element for the given instance."""
2244        instanceObject.lib = plistlib.fromtree(libElement[0])
2245
2246    def readInfoElement(self, infoElement, instanceObject):
2247        """ Read the info element."""
2248        instanceObject.info = True
2249
2250    def readGlyphElement(self, glyphElement, instanceObject):
2251        """
2252        Read the glyph element, which could look like either one of these:
2253
2254        .. code-block:: xml
2255
2256            <glyph name="b" unicode="0x62"/>
2257
2258            <glyph name="b"/>
2259
2260            <glyph name="b">
2261                <master location="location-token-bbb" source="master-token-aaa2"/>
2262                <master glyphname="b.alt1" location="location-token-ccc" source="master-token-aaa3"/>
2263                <note>
2264                    This is an instance from an anisotropic interpolation.
2265                </note>
2266            </glyph>
2267        """
2268        glyphData = {}
2269        glyphName = glyphElement.attrib.get('name')
2270        if glyphName is None:
2271            raise DesignSpaceDocumentError("Glyph object without name attribute")
2272        mute = glyphElement.attrib.get("mute")
2273        if mute == "1":
2274            glyphData['mute'] = True
2275        # unicode
2276        unicodes = glyphElement.attrib.get('unicode')
2277        if unicodes is not None:
2278            try:
2279                unicodes = [int(u, 16) for u in unicodes.split(" ")]
2280                glyphData['unicodes'] = unicodes
2281            except ValueError:
2282                raise DesignSpaceDocumentError("unicode values %s are not integers" % unicodes)
2283
2284        for noteElement in glyphElement.findall('.note'):
2285            glyphData['note'] = noteElement.text
2286            break
2287        designLocation, userLocation = self.locationFromElement(glyphElement)
2288        if userLocation:
2289            raise DesignSpaceDocumentError(f'<glyph> element "{glyphName}" must only have design locations (using xvalue="").')
2290        if designLocation is not None:
2291            glyphData['instanceLocation'] = designLocation
2292        glyphSources = None
2293        for masterElement in glyphElement.findall('.masters/master'):
2294            fontSourceName = masterElement.attrib.get('source')
2295            designLocation, userLocation = self.locationFromElement(masterElement)
2296            if userLocation:
2297                raise DesignSpaceDocumentError(f'<master> element "{fontSourceName}" must only have design locations (using xvalue="").')
2298            masterGlyphName = masterElement.attrib.get('glyphname')
2299            if masterGlyphName is None:
2300                # if we don't read a glyphname, use the one we have
2301                masterGlyphName = glyphName
2302            d = dict(font=fontSourceName,
2303                     location=designLocation,
2304                     glyphName=masterGlyphName)
2305            if glyphSources is None:
2306                glyphSources = []
2307            glyphSources.append(d)
2308        if glyphSources is not None:
2309            glyphData['masters'] = glyphSources
2310        instanceObject.glyphs[glyphName] = glyphData
2311
2312    def readLib(self):
2313        """Read the lib element for the whole document."""
2314        for libElement in self.root.findall(".lib"):
2315            self.documentObject.lib = plistlib.fromtree(libElement[0])
2316
2317
2318class DesignSpaceDocument(LogMixin, AsDictMixin):
2319    """The DesignSpaceDocument object can read and write ``.designspace`` data.
2320    It imports the axes, sources, variable fonts and instances to very basic
2321    **descriptor** objects that store the data in attributes. Data is added to
2322    the document by creating such descriptor objects, filling them with data
2323    and then adding them to the document. This makes it easy to integrate this
2324    object in different contexts.
2325
2326    The **DesignSpaceDocument** object can be subclassed to work with
2327    different objects, as long as they have the same attributes. Reader and
2328    Writer objects can be subclassed as well.
2329
2330    **Note:** Python attribute names are usually camelCased, the
2331    corresponding `XML <document-xml-structure>`_ attributes are usually
2332    all lowercase.
2333
2334    .. code:: python
2335
2336        from fontTools.designspaceLib import DesignSpaceDocument
2337        doc = DesignSpaceDocument.fromfile("some/path/to/my.designspace")
2338        doc.formatVersion
2339        doc.elidedFallbackName
2340        doc.axes
2341        doc.locationLabels
2342        doc.rules
2343        doc.rulesProcessingLast
2344        doc.sources
2345        doc.variableFonts
2346        doc.instances
2347        doc.lib
2348
2349    """
2350
2351    def __init__(self, readerClass=None, writerClass=None):
2352        self.path = None
2353        """String, optional. When the document is read from the disk, this is
2354        the full path that was given to :meth:`read` or :meth:`fromfile`.
2355        """
2356        self.filename = None
2357        """String, optional. When the document is read from the disk, this is
2358        its original file name, i.e. the last part of its path.
2359
2360        When the document is produced by a Python script and still only exists
2361        in memory, the producing script can write here an indication of a
2362        possible "good" filename, in case one wants to save the file somewhere.
2363        """
2364
2365        self.formatVersion: Optional[str] = None
2366        """Format version for this document, as a string. E.g. "4.0" """
2367
2368        self.elidedFallbackName: Optional[str] = None
2369        """STAT Style Attributes Header field ``elidedFallbackNameID``.
2370
2371        See: `OTSpec STAT Style Attributes Header <https://docs.microsoft.com/en-us/typography/opentype/spec/stat#style-attributes-header>`_
2372
2373        .. versionadded:: 5.0
2374        """
2375
2376        self.axes: List[Union[AxisDescriptor, DiscreteAxisDescriptor]] = []
2377        """List of this document's axes."""
2378        self.locationLabels: List[LocationLabelDescriptor] = []
2379        """List of this document's STAT format 4 labels.
2380
2381        .. versionadded:: 5.0"""
2382        self.rules: List[RuleDescriptor] = []
2383        """List of this document's rules."""
2384        self.rulesProcessingLast: bool = False
2385        """This flag indicates whether the substitution rules should be applied
2386        before or after other glyph substitution features.
2387
2388        - False: before
2389        - True: after.
2390
2391        Default is False. For new projects, you probably want True. See
2392        the following issues for more information:
2393        `fontTools#1371 <https://github.com/fonttools/fonttools/issues/1371#issuecomment-590214572>`__
2394        `fontTools#2050 <https://github.com/fonttools/fonttools/issues/2050#issuecomment-678691020>`__
2395
2396        If you want to use a different feature altogether, e.g. ``calt``,
2397        use the lib key ``com.github.fonttools.varLib.featureVarsFeatureTag``
2398
2399        .. code:: xml
2400
2401            <lib>
2402                <dict>
2403                    <key>com.github.fonttools.varLib.featureVarsFeatureTag</key>
2404                    <string>calt</string>
2405                </dict>
2406            </lib>
2407        """
2408        self.sources: List[SourceDescriptor] = []
2409        """List of this document's sources."""
2410        self.variableFonts: List[VariableFontDescriptor] = []
2411        """List of this document's variable fonts.
2412
2413        .. versionadded:: 5.0"""
2414        self.instances: List[InstanceDescriptor] = []
2415        """List of this document's instances."""
2416        self.lib: Dict = {}
2417        """User defined, custom data associated with the whole document.
2418
2419        Use reverse-DNS notation to identify your own data.
2420        Respect the data stored by others.
2421        """
2422
2423        self.default: Optional[str] = None
2424        """Name of the default master.
2425
2426        This attribute is updated by the :meth:`findDefault`
2427        """
2428
2429        if readerClass is not None:
2430            self.readerClass = readerClass
2431        else:
2432            self.readerClass = BaseDocReader
2433        if writerClass is not None:
2434            self.writerClass = writerClass
2435        else:
2436            self.writerClass = BaseDocWriter
2437
2438    @classmethod
2439    def fromfile(cls, path, readerClass=None, writerClass=None):
2440        """Read a designspace file from ``path`` and return a new instance of
2441        :class:.
2442        """
2443        self = cls(readerClass=readerClass, writerClass=writerClass)
2444        self.read(path)
2445        return self
2446
2447    @classmethod
2448    def fromstring(cls, string, readerClass=None, writerClass=None):
2449        self = cls(readerClass=readerClass, writerClass=writerClass)
2450        reader = self.readerClass.fromstring(string, self)
2451        reader.read()
2452        if self.sources:
2453            self.findDefault()
2454        return self
2455
2456    def tostring(self, encoding=None):
2457        """Returns the designspace as a string. Default encoding ``utf-8``."""
2458        if encoding is str or (
2459            encoding is not None and encoding.lower() == "unicode"
2460        ):
2461            f = StringIO()
2462            xml_declaration = False
2463        elif encoding is None or encoding == "utf-8":
2464            f = BytesIO()
2465            encoding = "UTF-8"
2466            xml_declaration = True
2467        else:
2468            raise ValueError("unsupported encoding: '%s'" % encoding)
2469        writer = self.writerClass(f, self)
2470        writer.write(encoding=encoding, xml_declaration=xml_declaration)
2471        return f.getvalue()
2472
2473    def read(self, path):
2474        """Read a designspace file from ``path`` and populates the fields of
2475        ``self`` with the data.
2476        """
2477        if hasattr(path, "__fspath__"):  # support os.PathLike objects
2478            path = path.__fspath__()
2479        self.path = path
2480        self.filename = os.path.basename(path)
2481        reader = self.readerClass(path, self)
2482        reader.read()
2483        if self.sources:
2484            self.findDefault()
2485
2486    def write(self, path):
2487        """Write this designspace to ``path``."""
2488        if hasattr(path, "__fspath__"):  # support os.PathLike objects
2489            path = path.__fspath__()
2490        self.path = path
2491        self.filename = os.path.basename(path)
2492        self.updatePaths()
2493        writer = self.writerClass(path, self)
2494        writer.write()
2495
2496    def _posixRelativePath(self, otherPath):
2497        relative = os.path.relpath(otherPath, os.path.dirname(self.path))
2498        return posix(relative)
2499
2500    def updatePaths(self):
2501        """
2502        Right before we save we need to identify and respond to the following situations:
2503        In each descriptor, we have to do the right thing for the filename attribute.
2504
2505        ::
2506
2507            case 1.
2508            descriptor.filename == None
2509            descriptor.path == None
2510
2511            -- action:
2512            write as is, descriptors will not have a filename attr.
2513            useless, but no reason to interfere.
2514
2515
2516            case 2.
2517            descriptor.filename == "../something"
2518            descriptor.path == None
2519
2520            -- action:
2521            write as is. The filename attr should not be touched.
2522
2523
2524            case 3.
2525            descriptor.filename == None
2526            descriptor.path == "~/absolute/path/there"
2527
2528            -- action:
2529            calculate the relative path for filename.
2530            We're not overwriting some other value for filename, it should be fine
2531
2532
2533            case 4.
2534            descriptor.filename == '../somewhere'
2535            descriptor.path == "~/absolute/path/there"
2536
2537            -- action:
2538            there is a conflict between the given filename, and the path.
2539            So we know where the file is relative to the document.
2540            Can't guess why they're different, we just choose for path to be correct and update filename.
2541        """
2542        assert self.path is not None
2543        for descriptor in self.sources + self.instances:
2544            if descriptor.path is not None:
2545                # case 3 and 4: filename gets updated and relativized
2546                descriptor.filename = self._posixRelativePath(descriptor.path)
2547
2548    def addSource(self, sourceDescriptor: SourceDescriptor):
2549        """Add the given ``sourceDescriptor`` to ``doc.sources``."""
2550        self.sources.append(sourceDescriptor)
2551
2552    def addSourceDescriptor(self, **kwargs):
2553        """Instantiate a new :class:`SourceDescriptor` using the given
2554        ``kwargs`` and add it to ``doc.sources``.
2555        """
2556        source = self.writerClass.sourceDescriptorClass(**kwargs)
2557        self.addSource(source)
2558        return source
2559
2560    def addInstance(self, instanceDescriptor: InstanceDescriptor):
2561        """Add the given ``instanceDescriptor`` to :attr:`instances`."""
2562        self.instances.append(instanceDescriptor)
2563
2564    def addInstanceDescriptor(self, **kwargs):
2565        """Instantiate a new :class:`InstanceDescriptor` using the given
2566        ``kwargs`` and add it to :attr:`instances`.
2567        """
2568        instance = self.writerClass.instanceDescriptorClass(**kwargs)
2569        self.addInstance(instance)
2570        return instance
2571
2572    def addAxis(self, axisDescriptor: Union[AxisDescriptor, DiscreteAxisDescriptor]):
2573        """Add the given ``axisDescriptor`` to :attr:`axes`."""
2574        self.axes.append(axisDescriptor)
2575
2576    def addAxisDescriptor(self, **kwargs):
2577        """Instantiate a new :class:`AxisDescriptor` using the given
2578        ``kwargs`` and add it to :attr:`axes`.
2579
2580        The axis will be and instance of :class:`DiscreteAxisDescriptor` if
2581        the ``kwargs`` provide a ``value``, or a :class:`AxisDescriptor` otherwise.
2582        """
2583        if "values" in kwargs:
2584            axis = self.writerClass.discreteAxisDescriptorClass(**kwargs)
2585        else:
2586            axis = self.writerClass.axisDescriptorClass(**kwargs)
2587        self.addAxis(axis)
2588        return axis
2589
2590    def addRule(self, ruleDescriptor: RuleDescriptor):
2591        """Add the given ``ruleDescriptor`` to :attr:`rules`."""
2592        self.rules.append(ruleDescriptor)
2593
2594    def addRuleDescriptor(self, **kwargs):
2595        """Instantiate a new :class:`RuleDescriptor` using the given
2596        ``kwargs`` and add it to :attr:`rules`.
2597        """
2598        rule = self.writerClass.ruleDescriptorClass(**kwargs)
2599        self.addRule(rule)
2600        return rule
2601
2602    def addVariableFont(self, variableFontDescriptor: VariableFontDescriptor):
2603        """Add the given ``variableFontDescriptor`` to :attr:`variableFonts`.
2604
2605        .. versionadded:: 5.0
2606        """
2607        self.variableFonts.append(variableFontDescriptor)
2608
2609    def addVariableFontDescriptor(self, **kwargs):
2610        """Instantiate a new :class:`VariableFontDescriptor` using the given
2611        ``kwargs`` and add it to :attr:`variableFonts`.
2612
2613        .. versionadded:: 5.0
2614        """
2615        variableFont = self.writerClass.variableFontDescriptorClass(**kwargs)
2616        self.addVariableFont(variableFont)
2617        return variableFont
2618
2619    def addLocationLabel(self, locationLabelDescriptor: LocationLabelDescriptor):
2620        """Add the given ``locationLabelDescriptor`` to :attr:`locationLabels`.
2621
2622        .. versionadded:: 5.0
2623        """
2624        self.locationLabels.append(locationLabelDescriptor)
2625
2626    def addLocationLabelDescriptor(self, **kwargs):
2627        """Instantiate a new :class:`LocationLabelDescriptor` using the given
2628        ``kwargs`` and add it to :attr:`locationLabels`.
2629
2630        .. versionadded:: 5.0
2631        """
2632        locationLabel = self.writerClass.locationLabelDescriptorClass(**kwargs)
2633        self.addLocationLabel(locationLabel)
2634        return locationLabel
2635
2636    def newDefaultLocation(self):
2637        """Return a dict with the default location in design space coordinates."""
2638        # Without OrderedDict, output XML would be non-deterministic.
2639        # https://github.com/LettError/designSpaceDocument/issues/10
2640        loc = collections.OrderedDict()
2641        for axisDescriptor in self.axes:
2642            loc[axisDescriptor.name] = axisDescriptor.map_forward(
2643                axisDescriptor.default
2644            )
2645        return loc
2646
2647    def labelForUserLocation(self, userLocation: SimpleLocationDict) -> Optional[LocationLabelDescriptor]:
2648        """Return the :class:`LocationLabel` that matches the given
2649        ``userLocation``, or ``None`` if no such label exists.
2650
2651        .. versionadded:: 5.0
2652        """
2653        return next(
2654            (label for label in self.locationLabels if label.userLocation == userLocation), None
2655        )
2656
2657    def updateFilenameFromPath(self, masters=True, instances=True, force=False):
2658        """Set a descriptor filename attr from the path and this document path.
2659
2660        If the filename attribute is not None: skip it.
2661        """
2662        if masters:
2663            for descriptor in self.sources:
2664                if descriptor.filename is not None and not force:
2665                    continue
2666                if self.path is not None:
2667                    descriptor.filename = self._posixRelativePath(descriptor.path)
2668        if instances:
2669            for descriptor in self.instances:
2670                if descriptor.filename is not None and not force:
2671                    continue
2672                if self.path is not None:
2673                    descriptor.filename = self._posixRelativePath(descriptor.path)
2674
2675    def newAxisDescriptor(self):
2676        """Ask the writer class to make us a new axisDescriptor."""
2677        return self.writerClass.getAxisDecriptor()
2678
2679    def newSourceDescriptor(self):
2680        """Ask the writer class to make us a new sourceDescriptor."""
2681        return self.writerClass.getSourceDescriptor()
2682
2683    def newInstanceDescriptor(self):
2684        """Ask the writer class to make us a new instanceDescriptor."""
2685        return self.writerClass.getInstanceDescriptor()
2686
2687    def getAxisOrder(self):
2688        """Return a list of axis names, in the same order as defined in the document."""
2689        names = []
2690        for axisDescriptor in self.axes:
2691            names.append(axisDescriptor.name)
2692        return names
2693
2694    def getAxis(self, name):
2695        """Return the axis with the given ``name``, or ``None`` if no such axis exists."""
2696        for axisDescriptor in self.axes:
2697            if axisDescriptor.name == name:
2698                return axisDescriptor
2699        return None
2700
2701    def getLocationLabel(self, name: str) -> Optional[LocationLabelDescriptor]:
2702        """Return the top-level location label with the given ``name``, or
2703        ``None`` if no such label exists.
2704
2705        .. versionadded:: 5.0
2706        """
2707        for label in self.locationLabels:
2708            if label.name == name:
2709                return label
2710        return None
2711
2712    def map_forward(self, userLocation: SimpleLocationDict) -> SimpleLocationDict:
2713        """Map a user location to a design location.
2714
2715        Assume that missing coordinates are at the default location for that axis.
2716
2717        Note: the output won't be anisotropic, only the xvalue is set.
2718
2719        .. versionadded:: 5.0
2720        """
2721        return {
2722            axis.name: axis.map_forward(userLocation.get(axis.name, axis.default))
2723            for axis in self.axes
2724        }
2725
2726    def map_backward(self, designLocation: AnisotropicLocationDict) -> SimpleLocationDict:
2727        """Map a design location to a user location.
2728
2729        Assume that missing coordinates are at the default location for that axis.
2730
2731        When the input has anisotropic locations, only the xvalue is used.
2732
2733        .. versionadded:: 5.0
2734        """
2735        return {
2736            axis.name: (
2737                axis.map_backward(designLocation[axis.name])
2738                if axis.name in designLocation
2739                else axis.default
2740            )
2741            for axis in self.axes
2742        }
2743
2744    def findDefault(self):
2745        """Set and return SourceDescriptor at the default location or None.
2746
2747        The default location is the set of all `default` values in user space
2748        of all axes.
2749
2750        This function updates the document's :attr:`default` value.
2751
2752        .. versionchanged:: 5.0
2753           Allow the default source to not specify some of the axis values, and
2754           they are assumed to be the default.
2755           See :meth:`SourceDescriptor.getFullDesignLocation()`
2756        """
2757        self.default = None
2758
2759        # Convert the default location from user space to design space before comparing
2760        # it against the SourceDescriptor locations (always in design space).
2761        defaultDesignLocation = self.newDefaultLocation()
2762
2763        for sourceDescriptor in self.sources:
2764            if sourceDescriptor.getFullDesignLocation(self) == defaultDesignLocation:
2765                self.default = sourceDescriptor
2766                return sourceDescriptor
2767
2768        return None
2769
2770    def normalizeLocation(self, location):
2771        """Return a dict with normalized axis values."""
2772        from fontTools.varLib.models import normalizeValue
2773
2774        new = {}
2775        for axis in self.axes:
2776            if axis.name not in location:
2777                # skipping this dimension it seems
2778                continue
2779            value = location[axis.name]
2780            # 'anisotropic' location, take first coord only
2781            if isinstance(value, tuple):
2782                value = value[0]
2783            triple = [
2784                axis.map_forward(v) for v in (axis.minimum, axis.default, axis.maximum)
2785            ]
2786            new[axis.name] = normalizeValue(value, triple)
2787        return new
2788
2789    def normalize(self):
2790        """
2791        Normalise the geometry of this designspace:
2792
2793        - scale all the locations of all masters and instances to the -1 - 0 - 1 value.
2794        - we need the axis data to do the scaling, so we do those last.
2795        """
2796        # masters
2797        for item in self.sources:
2798            item.location = self.normalizeLocation(item.location)
2799        # instances
2800        for item in self.instances:
2801            # glyph masters for this instance
2802            for _, glyphData in item.glyphs.items():
2803                glyphData['instanceLocation'] = self.normalizeLocation(glyphData['instanceLocation'])
2804                for glyphMaster in glyphData['masters']:
2805                    glyphMaster['location'] = self.normalizeLocation(glyphMaster['location'])
2806            item.location = self.normalizeLocation(item.location)
2807        # the axes
2808        for axis in self.axes:
2809            # scale the map first
2810            newMap = []
2811            for inputValue, outputValue in axis.map:
2812                newOutputValue = self.normalizeLocation({axis.name: outputValue}).get(axis.name)
2813                newMap.append((inputValue, newOutputValue))
2814            if newMap:
2815                axis.map = newMap
2816            # finally the axis values
2817            minimum = self.normalizeLocation({axis.name: axis.minimum}).get(axis.name)
2818            maximum = self.normalizeLocation({axis.name: axis.maximum}).get(axis.name)
2819            default = self.normalizeLocation({axis.name: axis.default}).get(axis.name)
2820            # and set them in the axis.minimum
2821            axis.minimum = minimum
2822            axis.maximum = maximum
2823            axis.default = default
2824        # now the rules
2825        for rule in self.rules:
2826            newConditionSets = []
2827            for conditions in rule.conditionSets:
2828                newConditions = []
2829                for cond in conditions:
2830                    if cond.get('minimum') is not None:
2831                        minimum = self.normalizeLocation({cond['name']: cond['minimum']}).get(cond['name'])
2832                    else:
2833                        minimum = None
2834                    if cond.get('maximum') is not None:
2835                        maximum = self.normalizeLocation({cond['name']: cond['maximum']}).get(cond['name'])
2836                    else:
2837                        maximum = None
2838                    newConditions.append(dict(name=cond['name'], minimum=minimum, maximum=maximum))
2839                newConditionSets.append(newConditions)
2840            rule.conditionSets = newConditionSets
2841
2842    def loadSourceFonts(self, opener, **kwargs):
2843        """Ensure SourceDescriptor.font attributes are loaded, and return list of fonts.
2844
2845        Takes a callable which initializes a new font object (e.g. TTFont, or
2846        defcon.Font, etc.) from the SourceDescriptor.path, and sets the
2847        SourceDescriptor.font attribute.
2848        If the font attribute is already not None, it is not loaded again.
2849        Fonts with the same path are only loaded once and shared among SourceDescriptors.
2850
2851        For example, to load UFO sources using defcon:
2852
2853            designspace = DesignSpaceDocument.fromfile("path/to/my.designspace")
2854            designspace.loadSourceFonts(defcon.Font)
2855
2856        Or to load masters as FontTools binary fonts, including extra options:
2857
2858            designspace.loadSourceFonts(ttLib.TTFont, recalcBBoxes=False)
2859
2860        Args:
2861            opener (Callable): takes one required positional argument, the source.path,
2862                and an optional list of keyword arguments, and returns a new font object
2863                loaded from the path.
2864            **kwargs: extra options passed on to the opener function.
2865
2866        Returns:
2867            List of font objects in the order they appear in the sources list.
2868        """
2869        # we load fonts with the same source.path only once
2870        loaded = {}
2871        fonts = []
2872        for source in self.sources:
2873            if source.font is not None:  # font already loaded
2874                fonts.append(source.font)
2875                continue
2876            if source.path in loaded:
2877                source.font = loaded[source.path]
2878            else:
2879                if source.path is None:
2880                    raise DesignSpaceDocumentError(
2881                        "Designspace source '%s' has no 'path' attribute"
2882                        % (source.name or "<Unknown>")
2883                    )
2884                source.font = opener(source.path, **kwargs)
2885                loaded[source.path] = source.font
2886            fonts.append(source.font)
2887        return fonts
2888
2889    @property
2890    def formatTuple(self):
2891        """Return the formatVersion as a tuple of (major, minor).
2892
2893        .. versionadded:: 5.0
2894        """
2895        if self.formatVersion is None:
2896            return (5, 0)
2897        numbers = (int(i) for i in self.formatVersion.split("."))
2898        major = next(numbers)
2899        minor = next(numbers, 0)
2900        return (major, minor)
2901
2902    def getVariableFonts(self) -> List[VariableFontDescriptor]:
2903        """Return all variable fonts defined in this document, or implicit
2904        variable fonts that can be built from the document's continuous axes.
2905
2906        In the case of Designspace documents before version 5, the whole
2907        document was implicitly describing a variable font that covers the
2908        whole space.
2909
2910        In version 5 and above documents, there can be as many variable fonts
2911        as there are locations on discrete axes.
2912
2913        .. seealso:: :func:`splitInterpolable`
2914
2915        .. versionadded:: 5.0
2916        """
2917        if self.variableFonts:
2918            return self.variableFonts
2919
2920        variableFonts = []
2921        discreteAxes = []
2922        rangeAxisSubsets: List[Union[RangeAxisSubsetDescriptor, ValueAxisSubsetDescriptor]] = []
2923        for axis in self.axes:
2924            if hasattr(axis, "values"):
2925                # Mypy doesn't support narrowing union types via hasattr()
2926                # TODO(Python 3.10): use TypeGuard
2927                # https://mypy.readthedocs.io/en/stable/type_narrowing.html
2928                axis = cast(DiscreteAxisDescriptor, axis)
2929                discreteAxes.append(axis)  # type: ignore
2930            else:
2931                rangeAxisSubsets.append(RangeAxisSubsetDescriptor(name=axis.name))
2932        valueCombinations = itertools.product(*[axis.values for axis in discreteAxes])
2933        for values in valueCombinations:
2934            basename = None
2935            if self.filename is not None:
2936                basename = os.path.splitext(self.filename)[0] + "-VF"
2937            if self.path is not None:
2938                basename = os.path.splitext(os.path.basename(self.path))[0] + "-VF"
2939            if basename is None:
2940                basename = "VF"
2941            axisNames = "".join([f"-{axis.tag}{value}" for axis, value in zip(discreteAxes, values)])
2942            variableFonts.append(VariableFontDescriptor(
2943                name=f"{basename}{axisNames}",
2944                axisSubsets=rangeAxisSubsets + [
2945                    ValueAxisSubsetDescriptor(name=axis.name, userValue=value)
2946                    for axis, value in zip(discreteAxes, values)
2947                ]
2948            ))
2949        return variableFonts
2950
2951    def deepcopyExceptFonts(self):
2952        """Allow deep-copying a DesignSpace document without deep-copying
2953        attached UFO fonts or TTFont objects. The :attr:`font` attribute
2954        is shared by reference between the original and the copy.
2955
2956        .. versionadded:: 5.0
2957        """
2958        fonts = [source.font for source in self.sources]
2959        try:
2960            for source in self.sources:
2961                source.font = None
2962            res = copy.deepcopy(self)
2963            for source, font in zip(res.sources, fonts):
2964                source.font = font
2965            return res
2966        finally:
2967            for source, font in zip(self.sources, fonts):
2968                source.font = font
2969
2970