Repository: fonttools/fonttools

Issue:
[designspace] Allow multiple <mappings> elements
Particularly in these early days of building avar version 2 designspace mappings, it would be useful if \<mapping\> elements could be logically grouped, with some kind of description for humans.

For example, implementing a single designspace "fence" requires multiple \<mapping\> elements: one to bring the overflowing values back to the "fence", and another to "pin" values at the fence to ensure values already contained by the fence are not remapped. Thus one conceptual action requires multiple encoded items, and it could be helpful to allow a description attribute to record the purpose of each set of mappings.

While a new \<group\> element within \<mappings\> would be one solution, @behdad suggests a simpler one: allow multiple \<mappings\> elements, each containing multiple \<mapping\> elements. The encoder would treat them as a single \<mappings\> element with all the \<mapping\> elements concatenated.

Example:

```xml
<mappings description="Handle upper XTRA fence when wght is default">
   <mapping>…</mapping>
   <mapping>…</mapping>
</mappings>
<mappings description="Handle upper XTRA fence when wght is not default">
   <mapping>…</mapping>
   <mapping>…</mapping>
   <mapping>…</mapping>
   <mapping>…</mapping>
</mappings>
```

Notes:
* Round-trip is not possible. (This is not seen as a problem.)
* Font editors may offer logical groupings (with description) for avar version 2 mappings, that would get saved into designspace as \<mappings\> groups with description attribute.


Discussion:
> * Round-trip is not possible. (This is not seen as a problem.)

Even that can possibly worked out; but I think it's okay not to.
All this per axis/dimension.
> All this per axis/dimension.

A "description" attribute on axis/dimension you mean?
It’s purely for human convenience, so we can see how it works best to split things up.
> For example, implementing a single designspace "fence" requires multiple <mapping> elements: one to bring the overflowing values back to the "fence", and another to "pin" values at the fence to ensure values already contained by the fence are not remapped. 

Here's a concrete example of this:

https://github.com/googlefonts/roboto-flex-avar2/blob/712b2662a3715f4d3ba8175453b3acab545770bf/Source/Parametric-avar2/RobotoAvar2-avar2-fences.designspace#L179-L239

> Thus one conceptual action requires multiple encoded items, and it could be helpful to allow a description attribute to record the purpose of each set of mappings.

While I 100% support this proposal, I also believe that "one conceptual action requires multiple encoded items" suggests that there ought to be a higher level markup to only capture the conceptual action and have the implications worked out by the compiler. 
@anthrotype @belluzj @madig any objections?
Sounds good, no objections from me



Patch:
diff --git a/Doc/source/designspaceLib/xml.rst b/Doc/source/designspaceLib/xml.rst
index f6baa54e51..7b59dbb17a 100644
--- a/Doc/source/designspaceLib/xml.rst
+++ b/Doc/source/designspaceLib/xml.rst
@@ -263,11 +263,14 @@ Example of all axis elements together
 ``<mappings>`` element
 ======================
 
--  Define axis mappings.
+-  Define an axis mappings group.
 -  Child element of ``axes``
 
+.. rubric:: Attributes
 
- .. versionadded:: 5.1
+- ``description``: optional, string. the description of this mappings group
+
+ .. versionadded:: 5.2
 
 
 ``<mapping>`` element
@@ -276,8 +279,11 @@ Example of all axis elements together
 -  Defines an axis mapping.
 -  Child element of ``<mappings>``
 
+.. rubric:: Attributes
 
- .. versionadded:: 5.1
+- ``description``: optional, string. the description of this mapping
+
+ .. versionadded:: 5.2
 
 
 ``<input>`` element
diff --git a/Lib/fontTools/designspaceLib/__init__.py b/Lib/fontTools/designspaceLib/__init__.py
index 69d4912c09..d6789f5f61 100644
--- a/Lib/fontTools/designspaceLib/__init__.py
+++ b/Lib/fontTools/designspaceLib/__init__.py
@@ -476,7 +476,14 @@ class AxisMappingDescriptor(SimpleDescriptor):
 
     _attrs = ["inputLocation", "outputLocation"]
 
-    def __init__(self, *, inputLocation=None, outputLocation=None):
+    def __init__(
+        self,
+        *,
+        inputLocation=None,
+        outputLocation=None,
+        description=None,
+        groupDescription=None,
+    ):
         self.inputLocation: SimpleLocationDict = inputLocation or {}
         """dict. Axis values for the input of the mapping, in design space coordinates.
 
@@ -491,6 +498,20 @@ def __init__(self, *, inputLocation=None, outputLocation=None):
 
         .. versionadded:: 5.1
         """
+        self.description = description
+        """string. A description of the mapping.
+
+        varLib.
+
+        .. versionadded:: 5.2
+        """
+        self.groupDescription = groupDescription
+        """string. A description of the group of mappings.
+
+        varLib.
+
+        .. versionadded:: 5.2
+        """
 
 
 class InstanceDescriptor(SimpleDescriptor):
@@ -1421,10 +1442,19 @@ def write(self, pretty=True, encoding="UTF-8", xml_declaration=True):
             self._addAxis(axisObject)
 
         if self.documentObject.axisMappings:
-            mappingsElement = ET.Element("mappings")
-            self.root.findall(".axes")[0].append(mappingsElement)
+            mappingsElement = None
+            lastGroup = object()
             for mappingObject in self.documentObject.axisMappings:
+                if getattr(mappingObject, "groupDescription", None) != lastGroup:
+                    if mappingsElement is not None:
+                        self.root.findall(".axes")[0].append(mappingsElement)
+                    lastGroup = getattr(mappingObject, "groupDescription", None)
+                    mappingsElement = ET.Element("mappings")
+                    if lastGroup is not None:
+                        mappingsElement.attrib["description"] = lastGroup
                 self._addAxisMapping(mappingsElement, mappingObject)
+            if mappingsElement is not None:
+                self.root.findall(".axes")[0].append(mappingsElement)
 
         if self.documentObject.locationLabels:
             labelsElement = ET.Element("labels")
@@ -1586,6 +1616,8 @@ def _addAxis(self, axisObject):
 
     def _addAxisMapping(self, mappingsElement, mappingObject):
         mappingElement = ET.Element("mapping")
+        if getattr(mappingObject, "description", None) is not None:
+            mappingElement.attrib["description"] = mappingObject.description
         for what in ("inputLocation", "outputLocation"):
             whatObject = getattr(mappingObject, what, None)
             if whatObject is None:
@@ -2081,10 +2113,11 @@ def readAxes(self):
             self.documentObject.axes.append(axisObject)
             self.axisDefaults[axisObject.name] = axisObject.default
 
-        mappingsElement = self.root.find(".axes/mappings")
         self.documentObject.axisMappings = []
-        if mappingsElement is not None:
+        for mappingsElement in self.root.findall(".axes/mappings"):
+            groupDescription = mappingsElement.attrib.get("description")
             for mappingElement in mappingsElement.findall("mapping"):
+                description = mappingElement.attrib.get("description")
                 inputElement = mappingElement.find("input")
                 outputElement = mappingElement.find("output")
                 inputLoc = {}
@@ -2098,7 +2131,10 @@ def readAxes(self):
                     value = float(dimElement.attrib["xvalue"])
                     outputLoc[name] = value
                 axisMappingObject = self.axisMappingDescriptorClass(
-                    inputLocation=inputLoc, outputLocation=outputLoc
+                    inputLocation=inputLoc,
+                    outputLocation=outputLoc,
+                    description=description,
+                    groupDescription=groupDescription,
                 )
                 self.documentObject.axisMappings.append(axisMappingObject)
 
@@ -3279,3 +3315,23 @@ def deepcopyExceptFonts(self):
         finally:
             for source, font in zip(self.sources, fonts):
                 source.font = font
+
+
+def main(args=None):
+    """Roundtrip .designspace file through the DesignSpaceDocument class"""
+
+    if args is None:
+        import sys
+
+        args = sys.argv[1:]
+
+    from argparse import ArgumentParser
+
+    parser = ArgumentParser(prog="designspaceLib", description=main.__doc__)
+    parser.add_argument("input")
+    parser.add_argument("output")
+
+    options = parser.parse_args(args)
+
+    ds = DesignSpaceDocument.fromfile(options.input)
+    ds.write(options.output)
diff --git a/Lib/fontTools/designspaceLib/__main__.py b/Lib/fontTools/designspaceLib/__main__.py
new file mode 100644
index 0000000000..8f5e44ea9e
--- /dev/null
+++ b/Lib/fontTools/designspaceLib/__main__.py
@@ -0,0 +1,6 @@
+import sys
+from fontTools.designspaceLib import main
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/Lib/fontTools/subset/__init__.py b/Lib/fontTools/subset/__init__.py
index 9b1758435c..250a07ef1a 100644
--- a/Lib/fontTools/subset/__init__.py
+++ b/Lib/fontTools/subset/__init__.py
@@ -3733,6 +3733,3 @@ def main(args=None):
     "parse_unicodes",
     "main",
 ]
-
-if __name__ == "__main__":
-    sys.exit(main())


Test patch:
diff --git a/Tests/designspaceLib/data/test_avar2.designspace b/Tests/designspaceLib/data/test_avar2.designspace
index d54588a66c..7c482a9bf5 100644
--- a/Tests/designspaceLib/data/test_avar2.designspace
+++ b/Tests/designspaceLib/data/test_avar2.designspace
@@ -19,8 +19,8 @@
       <map input="87.5" output="89"/>
       <map input="100" output="100"/>
     </axis>
-    <mappings>
-      <mapping>
+    <mappings description="all mappings">
+      <mapping description="test mapping">
         <input>
 	  <dimension name="Justify" xvalue="-100"/>
 	  <dimension name="Width" xvalue="100"/>
diff --git a/Tests/designspaceLib/designspace_test.py b/Tests/designspaceLib/designspace_test.py
index ceddfd10fb..52775bda2a 100644
--- a/Tests/designspaceLib/designspace_test.py
+++ b/Tests/designspaceLib/designspace_test.py
@@ -720,6 +720,12 @@ def test_axisMappingsRoundtrip(tmpdir):
     assert [mapping.outputLocation for mapping in doc.axisMappings] == [
         mapping.outputLocation for mapping in doc2.axisMappings
     ]
+    assert [mapping.description for mapping in doc.axisMappings] == [
+        mapping.description for mapping in doc2.axisMappings
+    ]
+    assert [mapping.groupDescription for mapping in doc.axisMappings] == [
+        mapping.groupDescription for mapping in doc2.axisMappings
+    ]
 
 
 def test_rulesConditions(tmpdir):