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