diff --git a/Lib/fontTools/designspaceLib/__init__.py b/Lib/fontTools/designspaceLib/__init__.py index 4b7068276..4af6882f3 100644 --- a/Lib/fontTools/designspaceLib/__init__.py +++ b/Lib/fontTools/designspaceLib/__init__.py @@ -1,13 +1,19 @@ -# -*- coding: utf-8 -*- +from __future__ import annotations -from fontTools.misc.loggingTools import LogMixin -from fontTools.misc.textTools import tobytes, tostr import collections -from io import BytesIO, StringIO +import copy +import itertools +import math import os import posixpath +from io import BytesIO, StringIO +from textwrap import indent +from typing import Any, Dict, List, MutableMapping, Optional, Tuple, Union + from fontTools.misc import etree as ET from fontTools.misc import plistlib +from fontTools.misc.loggingTools import LogMixin +from fontTools.misc.textTools import tobytes, tostr """ designSpaceDocument @@ -40,6 +46,7 @@ def posix(path): def posixpath_property(private_name): + """Generate a propery that holds a path always using forward slashes.""" def getter(self): # Normal getter return getattr(self, private_name) @@ -93,16 +100,41 @@ class SimpleDescriptor(AsDictMixin): except AssertionError: print("failed attribute", attr, getattr(self, attr), "!=", getattr(other, attr)) + def __repr__(self): + attrs = [f"{a}={repr(getattr(self, a))}," for a in self._attrs] + attrs = indent('\n'.join(attrs), ' ') + return f"{self.__class__.__name__}(\n{attrs}\n)" + class SourceDescriptor(SimpleDescriptor): - """Simple container for data related to the source""" + """Simple container for data related to the source + + .. code:: python + + doc = DesignSpaceDocument() + s1 = SourceDescriptor() + s1.path = masterPath1 + s1.name = "master.ufo1" + s1.font = defcon.Font("master.ufo1") + s1.location = dict(weight=0) + s1.familyName = "MasterFamilyName" + s1.styleName = "MasterStyleNameOne" + s1.localisedFamilyName = dict(fr="Caractère") + s1.mutedGlyphNames.append("A") + s1.mutedGlyphNames.append("Z") + doc.addSource(s1) + + """ flavor = "source" _attrs = ['filename', 'path', 'name', 'layerName', 'location', 'copyLib', 'copyGroups', 'copyFeatures', 'muteKerning', 'muteInfo', 'mutedGlyphNames', - 'familyName', 'styleName'] + 'familyName', 'styleName', 'localisedFamilyName'] + + filename = posixpath_property("_filename") + path = posixpath_property("_path") def __init__( self, @@ -112,9 +144,11 @@ class SourceDescriptor(SimpleDescriptor): font=None, name=None, location=None, + designLocation=None, layerName=None, familyName=None, styleName=None, + localisedFamilyName=None, copyLib=False, copyInfo=False, copyGroups=False, @@ -124,8 +158,10 @@ class SourceDescriptor(SimpleDescriptor): mutedGlyphNames=None, ): self.filename = filename - """The original path as found in the document.""" + """string. A relative path to the source file, **as it is in the document**. + MutatorMath + VarLib. + """ self.path = path """The absolute path, calculated from filename.""" @@ -142,27 +178,158 @@ class SourceDescriptor(SimpleDescriptor): """ self.name = name - self.location = location + """string. Optional. Unique identifier name for this source. + + MutatorMath + Varlib. + """ + + self.designLocation = designLocation if designLocation is not None else location or {} + """dict. Axis values for this source, in design space coordinates. + + MutatorMath + Varlib. + + This may be only part of the full design location. + See :meth:`getFullDesignLocation()` + + .. versionadded:: 5.0 + """ + self.layerName = layerName + """string. The name of the layer in the source to look for + outline data. Default ``None`` which means ``foreground``. + """ self.familyName = familyName + """string. Family name of this source. Though this data + can be extracted from the font, it can be efficient to have it right + here. + + Varlib. + """ self.styleName = styleName + """string. Style name of this source. Though this data + can be extracted from the font, it can be efficient to have it right + here. + + Varlib. + """ + self.localisedFamilyName = localisedFamilyName or {} + """dict. A dictionary of localised family name strings, keyed by + language code. + + If present, will be used to build localized names for all instances. + + .. versionadded:: 5.0 + """ self.copyLib = copyLib - self.copyInfo = copyInfo - self.copyGroups = copyGroups - self.copyFeatures = copyFeatures - self.muteKerning = muteKerning - self.muteInfo = muteInfo - self.mutedGlyphNames = mutedGlyphNames or [] + """bool. Indicates if the contents of the font.lib need to + be copied to the instances. - path = posixpath_property("_path") - filename = posixpath_property("_filename") + MutatorMath. + + .. deprecated:: 5.0 + """ + self.copyInfo = copyInfo + """bool. Indicates if the non-interpolating font.info needs + to be copied to the instances. + + MutatorMath. + + .. deprecated:: 5.0 + """ + self.copyGroups = copyGroups + """bool. Indicates if the groups need to be copied to the + instances. + + MutatorMath. + + .. deprecated:: 5.0 + """ + self.copyFeatures = copyFeatures + """bool. Indicates if the feature text needs to be + copied to the instances. + + MutatorMath. + + .. deprecated:: 5.0 + """ + self.muteKerning = muteKerning + """bool. Indicates if the kerning data from this source + needs to be muted (i.e. not be part of the calculations). + + MutatorMath only. + """ + self.muteInfo = muteInfo + """bool. Indicated if the interpolating font.info data for + this source needs to be muted. + + MutatorMath only. + """ + self.mutedGlyphNames = mutedGlyphNames or [] + """list. Glyphnames that need to be muted in the + instances. + + MutatorMath only. + """ + + @property + def location(self): + """dict. Axis values for this source, in design space coordinates. + + MutatorMath + Varlib. + + .. deprecated:: 5.0 + Use the more explicit alias for this property :attr:`designLocation`. + """ + return self.designLocation + + @location.setter + def location(self, location: Optional[AnisotropicLocationDict]): + self.designLocation = location or {} + + def setFamilyName(self, familyName, languageCode="en"): + """Setter for :attr:`localisedFamilyName` + + .. versionadded:: 5.0 + """ + self.localisedFamilyName[languageCode] = tostr(familyName) + + def getFamilyName(self, languageCode="en"): + """Getter for :attr:`localisedFamilyName` + + .. versionadded:: 5.0 + """ + return self.localisedFamilyName.get(languageCode) + + + def getFullDesignLocation(self, doc: 'DesignSpaceDocument') -> AnisotropicLocationDict: + """Get the complete design location of this source, from its + :attr:`designLocation` and the document's axis defaults. + + .. versionadded:: 5.0 + """ + result: AnisotropicLocationDict = {} + for axis in doc.axes: + if axis.name in self.designLocation: + result[axis.name] = self.designLocation[axis.name] + else: + result[axis.name] = axis.map_forward(axis.default) + return result class RuleDescriptor(SimpleDescriptor): - """Represents the rule descriptor element + """Represents the rule descriptor element: a set of glyph substitutions to + trigger conditionally in some parts of the designspace. - .. code-block:: xml + .. code:: python + + r1 = RuleDescriptor() + r1.name = "unique.rule.name" + r1.conditionSets.append([dict(name="weight", minimum=-10, maximum=10), dict(...)]) + r1.conditionSets.append([dict(...), dict(...)]) + r1.subs.append(("a", "a.alt")) + + .. code:: xml @@ -181,21 +348,36 @@ class RuleDescriptor(SimpleDescriptor): def __init__(self, *, name=None, conditionSets=None, subs=None): self.name = name + """string. Unique name for this rule. Can be used to reference this rule data.""" # list of lists of dict(name='aaaa', minimum=0, maximum=1000) self.conditionSets = conditionSets or [] + """a list of conditionsets. + + - Each conditionset is a list of conditions. + - Each condition is a dict with ``name``, ``minimum`` and ``maximum`` keys. + """ # list of substitutions stored as tuples of glyphnames ("a", "a.alt") self.subs = subs or [] + """list of substitutions. + + - Each substitution is stored as tuples of glyphnames, e.g. ("a", "a.alt"). + - Note: By default, rules are applied first, before other text + shaping/OpenType layout, as they are part of the + `Required Variation Alternates OpenType feature `_. + See ref:`rules-element` § Attributes. + """ def evaluateRule(rule, location): - """ Return True if any of the rule's conditionsets matches the given location.""" + """Return True if any of the rule's conditionsets matches the given location.""" return any(evaluateConditions(c, location) for c in rule.conditionSets) def evaluateConditions(conditions, location): - """ Return True if all the conditions matches the given location. - If a condition has no minimum, check for < maximum. - If a condition has no maximum, check for > minimum. + """Return True if all the conditions matches the given location. + + - If a condition has no minimum, check for < maximum. + - If a condition has no maximum, check for > minimum. """ for cd in conditions: value = location[cd['name']] @@ -211,8 +393,11 @@ def evaluateConditions(conditions, location): def processRules(rules, location, glyphNames): - """ Apply these rules at this location to these glyphnames - - rule order matters + """Apply these rules at this location to these glyphnames. + + Return a new list of glyphNames with substitutions applied. + + - rule order matters """ newNames = [] for rule in rules: @@ -232,22 +417,54 @@ def processRules(rules, location, glyphNames): return glyphNames +AnisotropicLocationDict = Dict[str, Union[float, Tuple[float, float]]] +SimpleLocationDict = Dict[str, float] + + class InstanceDescriptor(SimpleDescriptor): - """Simple container for data related to the instance""" + """Simple container for data related to the instance + + + .. code:: python + + i2 = InstanceDescriptor() + i2.path = instancePath2 + i2.familyName = "InstanceFamilyName" + i2.styleName = "InstanceStyleName" + i2.name = "instance.ufo2" + # anisotropic location + i2.designLocation = dict(weight=500, width=(400,300)) + i2.postScriptFontName = "InstancePostscriptName" + i2.styleMapFamilyName = "InstanceStyleMapFamilyName" + i2.styleMapStyleName = "InstanceStyleMapStyleName" + i2.lib['com.coolDesignspaceApp.specimenText'] = 'Hamburgerwhatever' + doc.addInstance(i2) + """ flavor = "instance" _defaultLanguageCode = "en" - _attrs = ['path', + _attrs = ['filename', + 'path', 'name', - 'location', + 'locationLabel', + 'designLocation', + 'userLocation', 'familyName', 'styleName', 'postScriptFontName', 'styleMapFamilyName', 'styleMapStyleName', + 'localisedFamilyName', + 'localisedStyleName', + 'localisedStyleMapFamilyName', + 'localisedStyleMapStyleName', + 'glyphs', 'kerning', 'info', 'lib'] + filename = posixpath_property("_filename") + path = posixpath_property("_path") + def __init__( self, *, @@ -256,6 +473,9 @@ class InstanceDescriptor(SimpleDescriptor): font=None, name=None, location=None, + locationLabel=None, + designLocation=None, + userLocation=None, familyName=None, styleName=None, postScriptFontName=None, @@ -270,34 +490,148 @@ class InstanceDescriptor(SimpleDescriptor): info=True, lib=None, ): - # the original path as found in the document self.filename = filename - # the absolute path, calculated from filename + """string. Relative path to the instance file, **as it is + in the document**. The file may or may not exist. + + MutatorMath + VarLib. + """ self.path = path - # Same as in SourceDescriptor. + """string. Absolute path to the instance file, calculated from + the document path and the string in the filename attr. The file may + or may not exist. + + MutatorMath. + """ self.font = font + """Same as :attr:`SourceDescriptor.font` + + .. seealso:: :attr:`SourceDescriptor.font` + """ self.name = name - self.location = location + """string. Unique identifier name of the instance, used to + identify it if it needs to be referenced from elsewhere in the + document. + """ + self.locationLabel = locationLabel + """Name of a :class:`LocationLabelDescriptor`. If + provided, the instance should have the same location as the + LocationLabel. + + .. seealso:: + :meth:`getFullDesignLocation` + :meth:`getFullUserLocation` + + .. versionadded:: 5.0 + """ + self.designLocation: AnisotropicLocationDict = designLocation if designLocation is not None else (location or {}) + """dict. Axis values for this instance, in design space coordinates. + + MutatorMath + Varlib. + + .. seealso:: This may be only part of the full location. See: + :meth:`getFullDesignLocation` + :meth:`getFullUserLocation` + + .. versionadded:: 5.0 + """ + self.userLocation: SimpleLocationDict = userLocation or {} + """dict. Axis values for this instance, in user space coordinates. + + MutatorMath + Varlib. + + .. seealso:: This may be only part of the full location. See: + :meth:`getFullDesignLocation` + :meth:`getFullUserLocation` + + .. versionadded:: 5.0 + """ self.familyName = familyName + """string. Family name of this instance. + + MutatorMath + Varlib. + """ self.styleName = styleName + """string. Style name of this instance. + + MutatorMath + Varlib. + """ self.postScriptFontName = postScriptFontName + """string. Postscript fontname for this instance. + + MutatorMath + Varlib. + """ self.styleMapFamilyName = styleMapFamilyName + """string. StyleMap familyname for this instance. + + MutatorMath + Varlib. + """ self.styleMapStyleName = styleMapStyleName + """string. StyleMap stylename for this instance. + + MutatorMath + Varlib. + """ self.localisedFamilyName = localisedFamilyName or {} + """dict. A dictionary of localised family name + strings, keyed by language code. + """ self.localisedStyleName = localisedStyleName or {} + """dict. A dictionary of localised stylename + strings, keyed by language code. + """ self.localisedStyleMapFamilyName = localisedStyleMapFamilyName or {} + """A dictionary of localised style map + familyname strings, keyed by language code. + """ self.localisedStyleMapStyleName = localisedStyleMapStyleName or {} + """A dictionary of localised style map + stylename strings, keyed by language code. + """ self.glyphs = glyphs or {} + """dict for special master definitions for glyphs. If glyphs + need special masters (to record the results of executed rules for + example). + + MutatorMath. + + .. deprecated:: 5.0 + Use rules or sparse sources instead. + """ self.kerning = kerning + """ bool. Indicates if this instance needs its kerning + calculated. + + MutatorMath. + + .. deprecated:: 5.0 + """ self.info = info + """bool. Indicated if this instance needs the interpolating + font.info calculated. + + .. deprecated:: 5.0 + """ self.lib = lib or {} """Custom data associated with this instance.""" - path = posixpath_property("_path") - filename = posixpath_property("_filename") + @property + def location(self): + """dict. Axis values for this instance. + + MutatorMath + Varlib. + + .. deprecated:: 5.0 + Use the more explicit alias for this property :attr:`designLocation`. + """ + return self.designLocation + + @location.setter + def location(self, location: Optional[AnisotropicLocationDict]): + self.designLocation = location or {} def setStyleName(self, styleName, languageCode="en"): + """These methods give easier access to the localised names.""" self.localisedStyleName[languageCode] = tostr(styleName) def getStyleName(self, languageCode="en"): @@ -321,6 +655,106 @@ class InstanceDescriptor(SimpleDescriptor): def getStyleMapFamilyName(self, languageCode="en"): return self.localisedStyleMapFamilyName.get(languageCode) + def clearLocation(self, axisName: Optional[str] = None): + """Clear all location-related fields. Ensures that + :attr:``designLocation`` and :attr:``userLocation`` are dictionaries + (possibly empty if clearing everything). + + In order to update the location of this instance wholesale, a user + should first clear all the fields, then change the field(s) for which + they have data. + + .. code:: python + + instance.clearLocation() + instance.designLocation = {'Weight': (34, 36.5), 'Width': 100} + instance.userLocation = {'Opsz': 16} + + In order to update a single axis location, the user should only clear + that axis, then edit the values: + + .. code:: python + + instance.clearLocation('Weight') + instance.designLocation['Weight'] = (34, 36.5) + + Args: + axisName: if provided, only clear the location for that axis. + + .. versionadded:: 5.0 + """ + self.locationLabel = None + if axisName is None: + self.designLocation = {} + self.userLocation = {} + else: + if self.designLocation is None: + self.designLocation = {} + if axisName in self.designLocation: + del self.designLocation[axisName] + if self.userLocation is None: + self.userLocation = {} + if axisName in self.userLocation: + del self.userLocation[axisName] + + def getLocationLabelDescriptor(self, doc: 'DesignSpaceDocument') -> Optional[LocationLabelDescriptor]: + """Get the :class:`LocationLabelDescriptor` instance that matches + this instances's :attr:`locationLabel`. + + Raises if the named label can't be found. + + .. versionadded:: 5.0 + """ + if self.locationLabel is None: + return None + label = doc.getLocationLabel(self.locationLabel) + if label is None: + raise DesignSpaceDocumentError( + 'InstanceDescriptor.getLocationLabelDescriptor(): ' + f'unknown location label `{self.locationLabel}` in instance `{self.name}`.' + ) + return label + + def getFullDesignLocation(self, doc: 'DesignSpaceDocument') -> AnisotropicLocationDict: + """Get the complete design location of this instance, by combining data + from the various location fields, default axis values and mappings, and + top-level location labels. + + The source of truth for this instance's location is determined for each + axis independently by taking the first not-None field in this list: + + - ``locationLabel``: the location along this axis is the same as the + matching STAT format 4 label. No anisotropy. + - ``designLocation[axisName]``: the explicit design location along this + axis, possibly anisotropic. + - ``userLocation[axisName]``: the explicit user location along this + axis. No anisotropy. + - ``axis.default``: default axis value. No anisotropy. + + .. versionadded:: 5.0 + """ + label = self.getLocationLabelDescriptor(doc) + if label is not None: + return doc.map_forward(label.userLocation) # type: ignore + result: AnisotropicLocationDict = {} + for axis in doc.axes: + if axis.name in self.designLocation: + result[axis.name] = self.designLocation[axis.name] + elif axis.name in self.userLocation: + result[axis.name] = axis.map_forward(self.userLocation[axis.name]) + else: + result[axis.name] = axis.map_forward(axis.default) + return result + + def getFullUserLocation(self, doc: 'DesignSpaceDocument') -> SimpleLocationDict: + """Get the complete user location for this instance. + + .. seealso:: :meth:`getFullDesignLocation` + + .. versionadded:: 5.0 + """ + return doc.map_backward(self.getFullDesignLocation(doc)) + def tagForAxisName(name): # try to find or make a tag name for this axis name @@ -340,12 +774,91 @@ def tagForAxisName(name): return tag, dict(en=name) -class AxisDescriptor(SimpleDescriptor): - """ Simple container for the axis data - Add more localisations? - """ +class AbstractAxisDescriptor(SimpleDescriptor): flavor = "axis" - _attrs = ['tag', 'name', 'maximum', 'minimum', 'default', 'map'] + + def __init__( + self, + *, + tag=None, + name=None, + labelNames=None, + hidden=False, + map=None, + axisOrdering=None, + axisLabels=None, + ): + # opentype tag for this axis + self.tag = tag + """string. Four letter tag for this axis. Some might be + registered at the `OpenType + specification `__. + Privately-defined axis tags must begin with an uppercase letter and + use only uppercase letters or digits. + """ + # name of the axis used in locations + self.name = name + """string. Name of the axis as it is used in the location dicts. + + MutatorMath + Varlib. + """ + # names for UI purposes, if this is not a standard axis, + self.labelNames = labelNames or {} + """dict. When defining a non-registered axis, it will be + necessary to define user-facing readable names for the axis. Keyed by + xml:lang code. Values are required to be ``unicode`` strings, even if + they only contain ASCII characters. + """ + self.hidden = hidden + """bool. Whether this axis should be hidden in user interfaces. + """ + self.map = map or [] + """list of input / output values that can describe a warp of user space + to design space coordinates. If no map values are present, it is assumed + user space is the same as design space, as in [(minimum, minimum), + (maximum, maximum)]. + + Varlib. + """ + self.axisOrdering = axisOrdering + """STAT table field ``axisOrdering``. + + See: `OTSpec STAT Axis Record `_ + + .. versionadded:: 5.0 + """ + self.axisLabels: List[AxisLabelDescriptor] = axisLabels or [] + """STAT table entries for Axis Value Tables format 1, 2, 3. + + See: `OTSpec STAT Axis Value Tables `_ + + .. versionadded:: 5.0 + """ + + +class AxisDescriptor(AbstractAxisDescriptor): + """ Simple container for the axis data. + + Add more localisations? + + .. code:: python + + a1 = AxisDescriptor() + a1.minimum = 1 + a1.maximum = 1000 + a1.default = 400 + a1.name = "weight" + a1.tag = "wght" + a1.labelNames['fa-IR'] = "قطر" + a1.labelNames['en'] = "Wéíght" + a1.map = [(1.0, 10.0), (400.0, 66.0), (1000.0, 990.0)] + a1.axisOrdering = 1 + a1.axisLabels = [ + AxisLabelDescriptor(name="Regular", userValue=400, elidable=True) + ] + doc.addAxis(a1) + """ + _attrs = ['tag', 'name', 'maximum', 'minimum', 'default', 'map', 'axisOrdering', 'axisLabels'] def __init__( self, @@ -358,18 +871,34 @@ class AxisDescriptor(SimpleDescriptor): maximum=None, hidden=False, map=None, + axisOrdering=None, + axisLabels=None, ): - # opentype tag for this axis - self.tag = tag - # name of the axis used in locations - self.name = name - # names for UI purposes, if this is not a standard axis, - self.labelNames = labelNames or {} + super().__init__( + tag=tag, + name=name, + labelNames=labelNames, + hidden=hidden, + map=map, + axisOrdering=axisOrdering, + axisLabels=axisLabels, + ) self.minimum = minimum + """number. The minimum value for this axis in user space. + + MutatorMath + Varlib. + """ self.maximum = maximum + """number. The maximum value for this axis in user space. + + MutatorMath + Varlib. + """ self.default = default - self.hidden = hidden - self.map = map or [] + """number. The default value for this axis, i.e. when a new location is + created, this is the value this axis will get in user space. + + MutatorMath + Varlib. + """ def serialize(self): # output to a dict, used in testing @@ -382,9 +911,12 @@ class AxisDescriptor(SimpleDescriptor): default=self.default, hidden=self.hidden, map=self.map, + axisOrdering=self.axisOrdering, + axisLabels=self.axisLabels, ) def map_forward(self, v): + """Maps value from axis mapping's input (user) to output (design).""" from fontTools.varLib.models import piecewiseLinearMap if not self.map: @@ -392,18 +924,349 @@ class AxisDescriptor(SimpleDescriptor): return piecewiseLinearMap(v, {k: v for k, v in self.map}) def map_backward(self, v): + """Maps value from axis mapping's output (design) to input (user).""" from fontTools.varLib.models import piecewiseLinearMap + if isinstance(v, tuple): + v = v[0] if not self.map: return v return piecewiseLinearMap(v, {v: k for k, v in self.map}) +class DiscreteAxisDescriptor(AbstractAxisDescriptor): + """Container for discrete axis data. + + Use this for axes that do not interpolate. The main difference from a + continuous axis is that a continuous axis has a ``minimum`` and ``maximum``, + while a discrete axis has a list of ``values``. + + Example: an Italic axis with 2 stops, Roman and Italic, that are not + compatible. The axis still allows to bind together the full font family, + which is useful for the STAT table, however it can't become a variation + axis in a VF. + + .. code:: python + + a2 = DiscreteAxisDescriptor() + a2.values = [0, 1] + a2.name = "Italic" + a2.tag = "ITAL" + a2.labelNames['fr'] = "Italique" + a2.map = [(0, 0), (1, -11)] + a2.axisOrdering = 2 + a2.axisLabels = [ + AxisLabelDescriptor(name="Roman", userValue=0, elidable=True) + ] + doc.addAxis(a2) + + .. versionadded:: 5.0 + """ + + flavor = "axis" + _attrs = ('tag', 'name', 'values', 'default', 'map', 'axisOrdering', 'axisLabels') + + def __init__( + self, + *, + tag=None, + name=None, + labelNames=None, + values=None, + default=None, + hidden=False, + map=None, + axisOrdering=None, + axisLabels=None, + ): + super().__init__( + tag=tag, + name=name, + labelNames=labelNames, + hidden=hidden, + map=map, + axisOrdering=axisOrdering, + axisLabels=axisLabels, + ) + self.default: float = default + """The default value for this axis, i.e. when a new location is + created, this is the value this axis will get in user space. + + However, this default value is less important than in continuous axes: + + - it doesn't define the "neutral" version of outlines from which + deltas would apply, as this axis does not interpolate. + - it doesn't provide the reference glyph set for the designspace, as + fonts at each value can have different glyph sets. + """ + self.values: List[float] = values or [] + """List of possible values for this axis. Contrary to continuous axes, + only the values in this list can be taken by the axis, nothing in-between. + """ + + def map_forward(self, value): + """Maps value from axis mapping's input to output. + + Returns value unchanged if no mapping entry is found. + + Note: for discrete axes, each value must have its mapping entry, if + you intend that value to be mapped. + """ + return next((v for k, v in self.map if k == value), value) + + def map_backward(self, value): + """Maps value from axis mapping's output to input. + + Returns value unchanged if no mapping entry is found. + + Note: for discrete axes, each value must have its mapping entry, if + you intend that value to be mapped. + """ + if isinstance(value, tuple): + value = value[0] + return next((k for k, v in self.map if v == value), value) + + +class AxisLabelDescriptor(SimpleDescriptor): + """Container for axis label data. + + Analogue of OpenType's STAT data for a single axis (formats 1, 2 and 3). + All values are user values. + See: `OTSpec STAT Axis value table, format 1, 2, 3 `_ + + The STAT format of the Axis value depends on which field are filled-in, + see :meth:`getFormat` + + .. versionadded:: 5.0 + """ + + flavor = "label" + _attrs = ('userMinimum', 'userValue', 'userMaximum', 'name', 'elidable', 'olderSibling', 'linkedUserValue', 'labelNames') + + def __init__( + self, + *, + name, + userValue, + userMinimum=None, + userMaximum=None, + elidable=False, + olderSibling=False, + linkedUserValue=None, + labelNames=None, + ): + self.userMinimum: Optional[float] = userMinimum + """STAT field ``rangeMinValue`` (format 2).""" + self.userValue: float = userValue + """STAT field ``value`` (format 1, 3) or ``nominalValue`` (format 2).""" + self.userMaximum: Optional[float] = userMaximum + """STAT field ``rangeMaxValue`` (format 2).""" + self.name: str = name + """Label for this axis location, STAT field ``valueNameID``.""" + self.elidable: bool = elidable + """STAT flag ``ELIDABLE_AXIS_VALUE_NAME``. + + See: `OTSpec STAT Flags `_ + """ + self.olderSibling: bool = olderSibling + """STAT flag ``OLDER_SIBLING_FONT_ATTRIBUTE``. + + See: `OTSpec STAT Flags `_ + """ + self.linkedUserValue: Optional[float] = linkedUserValue + """STAT field ``linkedValue`` (format 3).""" + self.labelNames: MutableMapping[str, str] = labelNames or {} + """User-facing translations of this location's label. Keyed by + ``xml:lang`` code. + """ + + def getFormat(self) -> int: + """Determine which format of STAT Axis value to use to encode this label. + + =========== ========= =========== =========== =============== + STAT Format userValue userMinimum userMaximum linkedUserValue + =========== ========= =========== =========== =============== + 1 ✅ ❌ ❌ ❌ + 2 ✅ ✅ ✅ ❌ + 3 ✅ ❌ ❌ ✅ + =========== ========= =========== =========== =============== + """ + if self.linkedUserValue is not None: + return 3 + if self.userMinimum is not None or self.userMaximum is not None: + return 2 + return 1 + + @property + def defaultName(self) -> str: + """Return the English name from :attr:`labelNames` or the :attr:`name`.""" + return self.labelNames.get("en") or self.name + + +class LocationLabelDescriptor(SimpleDescriptor): + """Container for location label data. + + Analogue of OpenType's STAT data for a free-floating location (format 4). + All values are user values. + + See: `OTSpec STAT Axis value table, format 4 `_ + + .. versionadded:: 5.0 + """ + + flavor = "label" + _attrs = ('name', 'elidable', 'olderSibling', 'userLocation', 'labelNames') + + def __init__( + self, + *, + name, + userLocation, + elidable=False, + olderSibling=False, + labelNames=None, + ): + self.name: str = name + """Label for this named location, STAT field ``valueNameID``.""" + self.userLocation: SimpleLocationDict = userLocation or {} + """Location in user coordinates along each axis. + + If an axis is not mentioned, it is assumed to be at its default location. + + .. seealso:: This may be only part of the full location. See: + :meth:`getFullUserLocation` + """ + self.elidable: bool = elidable + """STAT flag ``ELIDABLE_AXIS_VALUE_NAME``. + + See: `OTSpec STAT Flags `_ + """ + self.olderSibling: bool = olderSibling + """STAT flag ``OLDER_SIBLING_FONT_ATTRIBUTE``. + + See: `OTSpec STAT Flags `_ + """ + self.labelNames: Dict[str, str] = labelNames or {} + """User-facing translations of this location's label. Keyed by + xml:lang code. + """ + + @property + def defaultName(self) -> str: + """Return the English name from :attr:`labelNames` or the :attr:`name`.""" + return self.labelNames.get("en") or self.name + + def getFullUserLocation(self, doc: 'DesignSpaceDocument') -> SimpleLocationDict: + """Get the complete user location of this label, by combining data + from the explicit user location and default axis values. + + .. versionadded:: 5.0 + """ + return { + axis.name: self.userLocation.get(axis.name, axis.default) + for axis in doc.axes + } + + +class VariableFontDescriptor(SimpleDescriptor): + """Container for variable fonts, sub-spaces of the Designspace. + + Use-cases: + + - From a single DesignSpace with discrete axes, define 1 variable font + per value on the discrete axes. Before version 5, you would have needed + 1 DesignSpace per such variable font, and a lot of data duplication. + - From a big variable font with many axes, define subsets of that variable + font that only include some axes and freeze other axes at a given location. + + .. versionadded:: 5.0 + """ + + flavor = "variable-font" + _attrs = ('filename', 'axisSubsets', 'lib') + + filename = posixpath_property("_filename") + + def __init__(self, *, name, filename=None, axisSubsets=None, lib=None): + self.name: str = name + """string, required. Name of this variable to identify it during the + build process and from other parts of the document, and also as a + filename in case the filename property is empty. + + VarLib. + """ + self.filename: str = filename + """string, optional. Relative path to the variable font file, **as it is + in the document**. The file may or may not exist. + + If not specified, the :attr:`name` will be used as a basename for the file. + """ + self.axisSubsets: List[Union[RangeAxisSubsetDescriptor, ValueAxisSubsetDescriptor]] = axisSubsets or [] + """Axis subsets to include in this variable font. + + If an axis is not mentioned, assume that we only want the default + location of that axis (same as a :class:`ValueAxisSubsetDescriptor`). + """ + self.lib: MutableMapping[str, Any] = lib or {} + """Custom data associated with this variable font.""" + + +class RangeAxisSubsetDescriptor(SimpleDescriptor): + """Subset of a continuous axis to include in a variable font. + + .. versionadded:: 5.0 + """ + flavor = "axis-subset" + _attrs = ('name', 'userMinimum', 'userDefault', 'userMaximum') + + def __init__(self, *, name, userMinimum=-math.inf, userDefault=None, userMaximum=math.inf): + self.name: str = name + """Name of the :class:`AxisDescriptor` to subset.""" + self.userMinimum: float = userMinimum + """New minimum value of the axis in the target variable font. + If not specified, assume the same minimum value as the full axis. + (default = ``-math.inf``) + """ + self.userDefault: Optional[float] = userDefault + """New default value of the axis in the target variable font. + If not specified, assume the same default value as the full axis. + (default = ``None``) + """ + self.userMaximum: float = userMaximum + """New maximum value of the axis in the target variable font. + If not specified, assume the same maximum value as the full axis. + (default = ``math.inf``) + """ + + +class ValueAxisSubsetDescriptor(SimpleDescriptor): + """Single value of a discrete or continuous axis to use in a variable font. + + .. versionadded:: 5.0 + """ + flavor = "axis-subset" + _attrs = ('name', 'userValue') + + def __init__(self, *, name, userValue): + self.name: str = name + """Name of the :class:`AxisDescriptor` or :class:`DiscreteAxisDescriptor` + to "snapshot" or "freeze". + """ + self.userValue: float = userValue + """Value in user coordinates at which to freeze the given axis.""" + + class BaseDocWriter(object): _whiteSpace = " " - ruleDescriptorClass = RuleDescriptor axisDescriptorClass = AxisDescriptor + discreteAxisDescriptorClass = DiscreteAxisDescriptor + axisLabelDescriptorClass = AxisLabelDescriptor + locationLabelDescriptorClass = LocationLabelDescriptor + ruleDescriptorClass = RuleDescriptor sourceDescriptorClass = SourceDescriptor + variableFontDescriptorClass = VariableFontDescriptor + valueAxisSubsetDescriptorClass = ValueAxisSubsetDescriptor + rangeAxisSubsetDescriptorClass = RangeAxisSubsetDescriptor instanceDescriptorClass = InstanceDescriptor @classmethod @@ -422,21 +1285,29 @@ class BaseDocWriter(object): def getRuleDescriptor(cls): return cls.ruleDescriptorClass() - def __init__(self, documentPath, documentObject): + def __init__(self, documentPath, documentObject: DesignSpaceDocument): self.path = documentPath self.documentObject = documentObject - self.documentVersion = "4.1" + self.effectiveFormatTuple = self._getEffectiveFormatTuple() self.root = ET.Element("designspace") - self.root.attrib['format'] = self.documentVersion - self._axes = [] # for use by the writer only - self._rules = [] # for use by the writer only def write(self, pretty=True, encoding="UTF-8", xml_declaration=True): - if self.documentObject.axes: - self.root.append(ET.Element("axes")) + self.root.attrib['format'] = ".".join(str(i) for i in self.effectiveFormatTuple) + + if self.documentObject.axes or self.documentObject.elidedFallbackName is not None: + axesElement = ET.Element("axes") + if self.documentObject.elidedFallbackName is not None: + axesElement.attrib['elidedfallbackname'] = self.documentObject.elidedFallbackName + self.root.append(axesElement) for axisObject in self.documentObject.axes: self._addAxis(axisObject) + if self.documentObject.locationLabels: + labelsElement = ET.Element("labels") + for labelObject in self.documentObject.locationLabels: + self._addLocationLabel(labelsElement, labelObject) + self.root.append(labelsElement) + if self.documentObject.rules: if getattr(self.documentObject, "rulesProcessingLast", False): attributes = {"processing": "last"} @@ -451,13 +1322,19 @@ class BaseDocWriter(object): for sourceObject in self.documentObject.sources: self._addSource(sourceObject) + if self.documentObject.variableFonts: + variableFontsElement = ET.Element("variable-fonts") + for variableFont in self.documentObject.variableFonts: + self._addVariableFont(variableFontsElement, variableFont) + self.root.append(variableFontsElement) + if self.documentObject.instances: self.root.append(ET.Element("instances")) for instanceObject in self.documentObject.instances: self._addInstance(instanceObject) if self.documentObject.lib: - self._addLib(self.documentObject.lib) + self._addLib(self.root, self.documentObject.lib, 2) tree = ET.ElementTree(self.root) tree.write( @@ -468,6 +1345,34 @@ class BaseDocWriter(object): pretty_print=pretty, ) + def _getEffectiveFormatTuple(self): + """Try to use the version specified in the document, or a sufficiently + recent version to be able to encode what the document contains. + """ + minVersion = self.documentObject.formatTuple + if ( + any( + isinstance(axis, DiscreteAxisDescriptor) or + axis.axisOrdering is not None or + axis.axisLabels + for axis in self.documentObject.axes + ) or + self.documentObject.locationLabels or + any( + source.localisedFamilyName + for source in self.documentObject.sources + ) or + self.documentObject.variableFonts or + any( + instance.locationLabel or + instance.userLocation + for instance in self.documentObject.instances + ) + ): + if minVersion < (5, 0): + minVersion = (5, 0) + return minVersion + def _makeLocationElement(self, locationObject, name=None): """ Convert Location dict to a locationElement.""" locElement = ET.Element("location") @@ -492,11 +1397,10 @@ class BaseDocWriter(object): def intOrFloat(self, num): if int(num) == num: return "%d" % num - return "%f" % num + return ("%f" % num).rstrip('0').rstrip('.') def _addRule(self, ruleObject): # if none of the conditions have minimum or maximum values, do not add the rule. - self._rules.append(ruleObject) ruleElement = ET.Element('rule') if ruleObject.name is not None: ruleElement.attrib['name'] = ruleObject.name @@ -524,32 +1428,102 @@ class BaseDocWriter(object): self.root.findall('.rules')[0].append(ruleElement) def _addAxis(self, axisObject): - self._axes.append(axisObject) axisElement = ET.Element('axis') axisElement.attrib['tag'] = axisObject.tag axisElement.attrib['name'] = axisObject.name - axisElement.attrib['minimum'] = self.intOrFloat(axisObject.minimum) - axisElement.attrib['maximum'] = self.intOrFloat(axisObject.maximum) - axisElement.attrib['default'] = self.intOrFloat(axisObject.default) - if axisObject.hidden: - axisElement.attrib['hidden'] = "1" - for languageCode, labelName in sorted(axisObject.labelNames.items()): - languageElement = ET.Element('labelname') - languageElement.attrib[XML_LANG] = languageCode - languageElement.text = labelName - axisElement.append(languageElement) + self._addLabelNames(axisElement, axisObject.labelNames) if axisObject.map: for inputValue, outputValue in axisObject.map: mapElement = ET.Element('map') mapElement.attrib['input'] = self.intOrFloat(inputValue) mapElement.attrib['output'] = self.intOrFloat(outputValue) axisElement.append(mapElement) + if axisObject.axisOrdering or axisObject.axisLabels: + labelsElement = ET.Element('labels') + if axisObject.axisOrdering is not None: + labelsElement.attrib['ordering'] = str(axisObject.axisOrdering) + for label in axisObject.axisLabels: + self._addAxisLabel(labelsElement, label) + axisElement.append(labelsElement) + if isinstance(axisObject, AxisDescriptor): + axisElement.attrib['minimum'] = self.intOrFloat(axisObject.minimum) + axisElement.attrib['maximum'] = self.intOrFloat(axisObject.maximum) + elif isinstance(axisObject, DiscreteAxisDescriptor): + axisElement.attrib['values'] = " ".join(self.intOrFloat(v) for v in axisObject.values) + axisElement.attrib['default'] = self.intOrFloat(axisObject.default) + if axisObject.hidden: + axisElement.attrib['hidden'] = "1" self.root.findall('.axes')[0].append(axisElement) + def _addAxisLabel(self, axisElement: ET.Element, label: AxisLabelDescriptor) -> None: + labelElement = ET.Element('label') + labelElement.attrib['uservalue'] = self.intOrFloat(label.userValue) + if label.userMinimum is not None: + labelElement.attrib['userminimum'] = self.intOrFloat(label.userMinimum) + if label.userMaximum is not None: + labelElement.attrib['usermaximum'] = self.intOrFloat(label.userMaximum) + labelElement.attrib['name'] = label.name + if label.elidable: + labelElement.attrib['elidable'] = "true" + if label.olderSibling: + labelElement.attrib['oldersibling'] = "true" + if label.linkedUserValue is not None: + labelElement.attrib['linkeduservalue'] = self.intOrFloat(label.linkedUserValue) + self._addLabelNames(labelElement, label.labelNames) + axisElement.append(labelElement) + + def _addLabelNames(self, parentElement, labelNames): + for languageCode, labelName in sorted(labelNames.items()): + languageElement = ET.Element('labelname') + languageElement.attrib[XML_LANG] = languageCode + languageElement.text = labelName + parentElement.append(languageElement) + + def _addLocationLabel(self, parentElement: ET.Element, label: LocationLabelDescriptor) -> None: + labelElement = ET.Element('label') + labelElement.attrib['name'] = label.name + if label.elidable: + labelElement.attrib['elidable'] = "true" + if label.olderSibling: + labelElement.attrib['oldersibling'] = "true" + self._addLabelNames(labelElement, label.labelNames) + self._addLocationElement(labelElement, userLocation=label.userLocation) + parentElement.append(labelElement) + + def _addLocationElement( + self, + parentElement, + *, + designLocation: AnisotropicLocationDict = None, + userLocation: SimpleLocationDict = None + ): + locElement = ET.Element("location") + for axis in self.documentObject.axes: + if designLocation is not None and axis.name in designLocation: + dimElement = ET.Element('dimension') + dimElement.attrib['name'] = axis.name + value = designLocation[axis.name] + if isinstance(value, tuple): + dimElement.attrib['xvalue'] = self.intOrFloat(value[0]) + dimElement.attrib['yvalue'] = self.intOrFloat(value[1]) + else: + dimElement.attrib['xvalue'] = self.intOrFloat(value) + locElement.append(dimElement) + elif userLocation is not None and axis.name in userLocation: + dimElement = ET.Element('dimension') + dimElement.attrib['name'] = axis.name + value = userLocation[axis.name] + dimElement.attrib['uservalue'] = self.intOrFloat(value) + locElement.append(dimElement) + if len(locElement) > 0: + parentElement.append(locElement) + def _addInstance(self, instanceObject): instanceElement = ET.Element('instance') if instanceObject.name is not None: instanceElement.attrib['name'] = instanceObject.name + if instanceObject.locationLabel is not None: + instanceElement.attrib['location'] = instanceObject.locationLabel if instanceObject.familyName is not None: instanceElement.attrib['familyname'] = instanceObject.familyName if instanceObject.styleName is not None: @@ -596,9 +1570,19 @@ class BaseDocWriter(object): localisedStyleMapFamilyNameElement.text = instanceObject.getStyleMapFamilyName(code) instanceElement.append(localisedStyleMapFamilyNameElement) - if instanceObject.location is not None: - locationElement, instanceObject.location = self._makeLocationElement(instanceObject.location) - instanceElement.append(locationElement) + if self.effectiveFormatTuple >= (5, 0): + if instanceObject.locationLabel is None: + self._addLocationElement( + instanceElement, + designLocation=instanceObject.designLocation, + userLocation=instanceObject.userLocation + ) + else: + # Pre-version 5.0 code was validating and filling in the location + # dict while writing it out, as preserved below. + if instanceObject.location is not None: + locationElement, instanceObject.location = self._makeLocationElement(instanceObject.location) + instanceElement.append(locationElement) if instanceObject.filename is not None: instanceElement.attrib['filename'] = instanceObject.filename if instanceObject.postScriptFontName is not None: @@ -607,24 +1591,23 @@ class BaseDocWriter(object): instanceElement.attrib['stylemapfamilyname'] = instanceObject.styleMapFamilyName if instanceObject.styleMapStyleName is not None: instanceElement.attrib['stylemapstylename'] = instanceObject.styleMapStyleName - if instanceObject.glyphs: - if instanceElement.findall('.glyphs') == []: - glyphsElement = ET.Element('glyphs') - instanceElement.append(glyphsElement) - glyphsElement = instanceElement.findall('.glyphs')[0] - for glyphName, data in sorted(instanceObject.glyphs.items()): - glyphElement = self._writeGlyphElement(instanceElement, instanceObject, glyphName, data) - glyphsElement.append(glyphElement) - if instanceObject.kerning: - kerningElement = ET.Element('kerning') - instanceElement.append(kerningElement) - if instanceObject.info: - infoElement = ET.Element('info') - instanceElement.append(infoElement) - if instanceObject.lib: - libElement = ET.Element('lib') - libElement.append(plistlib.totree(instanceObject.lib, indent_level=4)) - instanceElement.append(libElement) + if self.effectiveFormatTuple < (5, 0): + # Deprecated members as of version 5.0 + if instanceObject.glyphs: + if instanceElement.findall('.glyphs') == []: + glyphsElement = ET.Element('glyphs') + instanceElement.append(glyphsElement) + glyphsElement = instanceElement.findall('.glyphs')[0] + for glyphName, data in sorted(instanceObject.glyphs.items()): + glyphElement = self._writeGlyphElement(instanceElement, instanceObject, glyphName, data) + glyphsElement.append(glyphElement) + if instanceObject.kerning: + kerningElement = ET.Element('kerning') + instanceElement.append(kerningElement) + if instanceObject.info: + infoElement = ET.Element('info') + instanceElement.append(infoElement) + self._addLib(instanceElement, instanceObject.lib, 4) self.root.findall('.instances')[0].append(instanceElement) def _addSource(self, sourceObject): @@ -641,6 +1624,16 @@ class BaseDocWriter(object): sourceElement.attrib['stylename'] = sourceObject.styleName if sourceObject.layerName is not None: sourceElement.attrib['layer'] = sourceObject.layerName + if sourceObject.localisedFamilyName: + languageCodes = list(sourceObject.localisedFamilyName.keys()) + languageCodes.sort() + for code in languageCodes: + if code == "en": + continue # already stored in the element attribute + localisedFamilyNameElement = ET.Element('familyname') + localisedFamilyNameElement.attrib[XML_LANG] = code + localisedFamilyNameElement.text = sourceObject.getFamilyName(code) + sourceElement.append(localisedFamilyNameElement) if sourceObject.copyLib: libElement = ET.Element('lib') libElement.attrib['copy'] = "1" @@ -670,14 +1663,45 @@ class BaseDocWriter(object): glyphElement.attrib["name"] = name glyphElement.attrib["mute"] = '1' sourceElement.append(glyphElement) - locationElement, sourceObject.location = self._makeLocationElement(sourceObject.location) - sourceElement.append(locationElement) + if self.effectiveFormatTuple >= (5, 0): + self._addLocationElement(sourceElement, designLocation=sourceObject.location) + else: + # Pre-version 5.0 code was validating and filling in the location + # dict while writing it out, as preserved below. + locationElement, sourceObject.location = self._makeLocationElement(sourceObject.location) + sourceElement.append(locationElement) self.root.findall('.sources')[0].append(sourceElement) - def _addLib(self, dict): + def _addVariableFont(self, parentElement: ET.Element, vf: VariableFontDescriptor) -> None: + vfElement = ET.Element('variable-font') + vfElement.attrib['name'] = vf.name + if vf.filename is not None: + vfElement.attrib['filename'] = vf.filename + if vf.axisSubsets: + subsetsElement = ET.Element('axis-subsets') + for subset in vf.axisSubsets: + subsetElement = ET.Element('axis-subset') + subsetElement.attrib['name'] = subset.name + if isinstance(subset, RangeAxisSubsetDescriptor): + if subset.userMinimum != -math.inf: + subsetElement.attrib['userminimum'] = self.intOrFloat(subset.userMinimum) + if subset.userMaximum != math.inf: + subsetElement.attrib['usermaximum'] = self.intOrFloat(subset.userMaximum) + if subset.userDefault is not None: + subsetElement.attrib['userdefault'] = self.intOrFloat(subset.userDefault) + elif isinstance(subset, ValueAxisSubsetDescriptor): + subsetElement.attrib['uservalue'] = self.intOrFloat(subset.userValue) + subsetsElement.append(subsetElement) + vfElement.append(subsetsElement) + self._addLib(vfElement, vf.lib, 4) + parentElement.append(vfElement) + + def _addLib(self, parentElement: ET.Element, data: Any, indent_level: int) -> None: + if not data: + return libElement = ET.Element('lib') - libElement.append(plistlib.totree(dict, indent_level=2)) - self.root.append(libElement) + libElement.append(plistlib.totree(data, indent_level=indent_level)) + parentElement.append(libElement) def _writeGlyphElement(self, instanceElement, instanceObject, glyphName, data): glyphElement = ET.Element('glyph') @@ -711,9 +1735,15 @@ class BaseDocWriter(object): class BaseDocReader(LogMixin): - ruleDescriptorClass = RuleDescriptor axisDescriptorClass = AxisDescriptor + discreteAxisDescriptorClass = DiscreteAxisDescriptor + axisLabelDescriptorClass = AxisLabelDescriptor + locationLabelDescriptorClass = LocationLabelDescriptor + ruleDescriptorClass = RuleDescriptor sourceDescriptorClass = SourceDescriptor + variableFontsDescriptorClass = VariableFontDescriptor + valueAxisSubsetDescriptorClass = ValueAxisSubsetDescriptor + rangeAxisSubsetDescriptorClass = RangeAxisSubsetDescriptor instanceDescriptorClass = InstanceDescriptor def __init__(self, documentPath, documentObject): @@ -738,7 +1768,9 @@ class BaseDocReader(LogMixin): def read(self): self.readAxes() + self.readLabels() self.readRules() + self.readVariableFonts() self.readSources() self.readInstances() self.readLib() @@ -810,17 +1842,24 @@ class BaseDocReader(LogMixin): def readAxes(self): # read the axes elements, including the warp map. + axesElement = self.root.find(".axes") + if axesElement is not None and 'elidedfallbackname' in axesElement.attrib: + self.documentObject.elidedFallbackName = axesElement.attrib['elidedfallbackname'] axisElements = self.root.findall(".axes/axis") if not axisElements: return for axisElement in axisElements: - axisObject = self.axisDescriptorClass() + if self.documentObject.formatTuple >= (5, 0) and "values" in axisElement.attrib: + axisObject = self.discreteAxisDescriptorClass() + axisObject.values = [float(s) for s in axisElement.attrib["values"].split(" ")] + else: + axisObject = self.axisDescriptorClass() + axisObject.minimum = float(axisElement.attrib.get("minimum")) + axisObject.maximum = float(axisElement.attrib.get("maximum")) + axisObject.default = float(axisElement.attrib.get("default")) axisObject.name = axisElement.attrib.get("name") - axisObject.minimum = float(axisElement.attrib.get("minimum")) - axisObject.maximum = float(axisElement.attrib.get("maximum")) if axisElement.attrib.get('hidden', False): axisObject.hidden = True - axisObject.default = float(axisElement.attrib.get("default")) axisObject.tag = axisElement.attrib.get("tag") for mapElement in axisElement.findall('map'): a = float(mapElement.attrib['input']) @@ -832,9 +1871,172 @@ class BaseDocReader(LogMixin): for key, lang in labelNameElement.items(): if key == XML_LANG: axisObject.labelNames[lang] = tostr(labelNameElement.text) + labelElement = axisElement.find(".labels") + if labelElement is not None: + if "ordering" in labelElement.attrib: + axisObject.axisOrdering = int(labelElement.attrib["ordering"]) + for label in labelElement.findall(".label"): + axisObject.axisLabels.append(self.readAxisLabel(label)) self.documentObject.axes.append(axisObject) self.axisDefaults[axisObject.name] = axisObject.default + def readAxisLabel(self, element: ET.Element): + xml_attrs = {'userminimum', 'uservalue', 'usermaximum', 'name', 'elidable', 'oldersibling', 'linkeduservalue'} + unknown_attrs = set(element.attrib) - xml_attrs + if unknown_attrs: + raise DesignSpaceDocumentError(f"label element contains unknown attributes: {', '.join(unknown_attrs)}") + + name = element.get("name") + if name is None: + raise DesignSpaceDocumentError("label element must have a name attribute.") + valueStr = element.get("uservalue") + if valueStr is None: + raise DesignSpaceDocumentError("label element must have a uservalue attribute.") + value = float(valueStr) + minimumStr = element.get("userminimum") + minimum = float(minimumStr) if minimumStr is not None else None + maximumStr = element.get("usermaximum") + maximum = float(maximumStr) if maximumStr is not None else None + linkedValueStr = element.get("linkeduservalue") + linkedValue = float(linkedValueStr) if linkedValueStr is not None else None + elidable = True if element.get("elidable") == "true" else False + olderSibling = True if element.get("oldersibling") == "true" else False + labelNames = { + lang: label_name.text or "" + for label_name in element.findall("labelname") + for attr, lang in label_name.items() + if attr == XML_LANG + # Note: elementtree reads the "xml:lang" attribute name as + # '{http://www.w3.org/XML/1998/namespace}lang' + } + return self.axisLabelDescriptorClass( + name=name, + userValue=value, + userMinimum=minimum, + userMaximum=maximum, + elidable=elidable, + olderSibling=olderSibling, + linkedUserValue=linkedValue, + labelNames=labelNames, + ) + + def readLabels(self): + if self.documentObject.formatTuple < (5, 0): + return + + xml_attrs = {'name', 'elidable', 'oldersibling'} + for labelElement in self.root.findall(".labels/label"): + unknown_attrs = set(labelElement.attrib) - xml_attrs + if unknown_attrs: + raise DesignSpaceDocumentError(f"Label element contains unknown attributes: {', '.join(unknown_attrs)}") + + name = labelElement.get("name") + if name is None: + raise DesignSpaceDocumentError("label element must have a name attribute.") + designLocation, userLocation = self.locationFromElement(labelElement) + if designLocation: + raise DesignSpaceDocumentError(f'