Repository: googlefonts/glyphsLib

Issue:
Handle smart components
Account for these when exporting UFOs from Glyphs source:
https://glyphsapp.com/tutorials/smart-components

Discussion:

Patch:
diff --git a/Lib/glyphsLib/builder/axes.py b/Lib/glyphsLib/builder/axes.py
index cb724df9..cb3b7f2d 100644
--- a/Lib/glyphsLib/builder/axes.py
+++ b/Lib/glyphsLib/builder/axes.py
@@ -18,7 +18,7 @@ import logging
 from fontTools.varLib.models import piecewiseLinearMap
 
 from glyphsLib import classes
-from glyphsLib.classes import InstanceType, WEIGHT_CODES, WIDTH_CODES
+from glyphsLib.classes import WEIGHT_CODES, WIDTH_CODES, InstanceType
 from glyphsLib.builder.constants import WIDTH_CLASS_TO_VALUE
 
 logger = logging.getLogger(__name__)
@@ -125,6 +125,33 @@ def user_loc_value_to_instance_string(axis_tag, user_loc):
     )[0]
 
 
+def update_mapping_from_instances(
+    mapping, instances, axis_def, minimize_glyphs_diffs, cp_only=False
+):
+    # Collect the axis mappings from instances and update the mapping dict.
+    for instance in instances:
+        if instance.type == InstanceType.VARIABLE:
+            continue
+        if is_instance_active(instance) or minimize_glyphs_diffs:
+            designLoc = axis_def.get_design_loc(instance)
+            if cp_only:
+                # Only use the Axis Location custom parameter for the user location
+                userLoc = axis_def.get_user_loc_from_axis_location_cp(instance)
+            else:
+                # Use all heuristics to derive a user location
+                userLoc = axis_def.get_user_loc(instance)
+            if userLoc is None:
+                # May happen if the custom parameter is disabled
+                continue
+            if userLoc in mapping and mapping[userLoc] != designLoc:
+                logger.warning(
+                    f"Axis {axis_def.tag}: Instance '{instance.name}' redefines "
+                    f"the mapping for user location {userLoc} "
+                    f"from {mapping[userLoc]} to {designLoc}"
+                )
+            mapping[userLoc] = designLoc
+
+
 def to_designspace_axes(self):
     if not self.font.masters:
         return
@@ -165,40 +192,45 @@ def to_designspace_axes(self):
                 continue
         # See https://github.com/googlefonts/glyphsLib/issues/280
         elif font_uses_axis_locations(self.font):
-            # Build the mapping from the "Axis Location" of the masters
+            # If all masters have an "Axis Location" custom parameter, only the values
+            # from this parameter will be used to build the mapping of the masters and
+            # instances
             # TODO: (jany) use Virtual Masters as well?
+            #       (jenskutilek) virtual masters can't have an Axis Location parameter.
             mapping = {}
             for master in self.font.masters:
                 designLoc = axis_def.get_design_loc(master)
                 userLoc = axis_def.get_user_loc(master)
                 if userLoc in mapping and mapping[userLoc] != designLoc:
                     logger.warning(
-                        "Axis location (%s) was redefined by '%s'", userLoc, master.name
+                        f"Axis {axis_def.tag}: Master '{master.name}' redefines "
+                        f"the mapping for user location {userLoc} "
+                        f"from {mapping[userLoc]} to {designLoc}"
                     )
                 mapping[userLoc] = designLoc
 
+            update_mapping_from_instances(
+                mapping,
+                self.font.instances,
+                axis_def,
+                minimize_glyphs_diffs=self.minimize_glyphs_diffs,
+                # Glyphs doesn't deduce instance mappings if font uses axis locations.
+                # Use only the custom parameter if present.
+                cp_only=True,
+            )
+
             regularDesignLoc = axis_def.get_design_loc(regular_master)
             regularUserLoc = axis_def.get_user_loc(regular_master)
         else:
             # Build the mapping from the instances because they have both
             # a user location and a design location.
             instance_mapping = {}
-            for instance in self.font.instances:
-                if (
-                    is_instance_active(instance) or self.minimize_glyphs_diffs
-                ) and instance.type != InstanceType.VARIABLE:
-                    designLoc = axis_def.get_design_loc(instance)
-                    userLoc = axis_def.get_user_loc(instance)
-                    if (
-                        userLoc in instance_mapping
-                        and instance_mapping[userLoc] != designLoc
-                    ):
-                        logger.warning(
-                            "Instance user-space location (%s) redefined by " "'%s'",
-                            userLoc,
-                            instance.name,
-                        )
-                    instance_mapping[userLoc] = designLoc
+            update_mapping_from_instances(
+                instance_mapping,
+                self.font.instances,
+                axis_def,
+                minimize_glyphs_diffs=self.minimize_glyphs_diffs,
+            )
 
             master_mapping = {}
             for master in self.font.masters:
@@ -320,8 +352,8 @@ class AxisDefinition:
 
     def get_user_loc(self, master_or_instance):
         """Get the user location of a Glyphs master or instance.
-        Masters in Glyphs can have a user location in the "Axis Location"
-        custom parameter.
+        Masters and instances in Glyphs can have a user location in the
+        "Axis Location" custom parameter.
 
         The user location is what the user sees on the slider in his
         variable-font-enabled UI. For weight it is a value between 0 and 1000,
@@ -362,22 +394,27 @@ class AxisDefinition:
             if class_ is not None:
                 user_loc = class_to_value(self.tag, class_)
 
-        # Masters have a customParameter that specifies a user location
-        # along custom axes. If this is present it takes precedence over
-        # everything else.
+        axis_location = self.get_user_loc_from_axis_location_cp(master_or_instance)
+        if axis_location is not None:
+            user_loc = axis_location
+
+        return user_loc
+
+    def get_user_loc_from_axis_location_cp(self, master_or_instance):
+        # Masters and instances have a customParameter that specifies a user location
+        # along custom axes. If this is present it takes precedence over everything
+        # else.
         loc_param = master_or_instance.customParameters["Axis Location"]
         try:
             for location in loc_param:
                 if location.get("Axis") == self.name:
-                    user_loc = int(location["Location"])
+                    return int(location["Location"])
         except (TypeError, KeyError):
             pass
 
-        return user_loc
-
     def set_user_loc(self, master_or_instance, value):
         """Set the user location of a Glyphs master or instance."""
-        if hasattr(master_or_instance, "instanceInterpolations"):
+        if isinstance(master_or_instance, classes.GSInstance):
             # The following code is only valid for instances.
             # Masters also the keys `weight` and `width` but they should not be
             # used, they are deprecated and should only be used to store
@@ -405,9 +442,8 @@ class AxisDefinition:
                         pass
             return
 
-        # For masters, set directly the custom parameter (old way)
-        # and also the Axis Location (new way).
-        # Only masters can have an 'Axis Location' parameter.
+        # Directly set the custom parameter (old way) and also the Axis Location
+        # (new way).
         if self.user_loc_param is not None:
             try:
                 class_ = user_loc_value_to_class(self.tag, value)
diff --git a/Lib/glyphsLib/builder/components.py b/Lib/glyphsLib/builder/components.py
index 4687e8f1..1bfcc8ed 100644
--- a/Lib/glyphsLib/builder/components.py
+++ b/Lib/glyphsLib/builder/components.py
@@ -20,6 +20,7 @@ from fontTools.pens.recordingPen import DecomposingRecordingPen
 from glyphsLib.classes import GSBackgroundLayer
 from glyphsLib.types import Transform
 
+from .smart_components import to_ufo_smart_component
 from .constants import GLYPHS_PREFIX, COMPONENT_INFO_KEY, SMART_COMPONENT_AXES_LIB_KEY
 
 logger = logging.getLogger(__name__)
@@ -61,7 +62,14 @@ def to_ufo_components(self, ufo_glyph, layer):
 
     pen = ufo_glyph.getPointPen()
     for index, component in enumerate(layer.components):
-        pen.addComponent(component.name, component.transform)
+        # XXX We may also want to test here if we're compiling a font (and decompose
+        # if so) or changing the representation format (in which case we leave it
+        # as a component and save the smart component values).
+        # See https://github.com/googlefonts/glyphsLib/pull/822
+        if component.smartComponentValues and component.component.smartComponentAxes:
+            to_ufo_smart_component(self, layer, component, pen)
+        else:
+            pen.addComponent(component.name, component.transform)
 
         if not (component.anchor or component.alignment):
             continue
diff --git a/Lib/glyphsLib/builder/smart_components.py b/Lib/glyphsLib/builder/smart_components.py
new file mode 100644
index 00000000..3bbd0afe
--- /dev/null
+++ b/Lib/glyphsLib/builder/smart_components.py
@@ -0,0 +1,138 @@
+"""Convert Glyphs smart components.
+
+Smart components (https://glyphsapp.com/learn/smart-components) are a
+feature within Glyphs whereby a component can essentially define its
+own private designspace - each master of a component glyph can
+define its own axes and masters. When the component is used, instead
+of simply scaling or transforming the component, the designer can
+*interpolate* the component by specifying the location in the private
+designspace.
+
+For example, a font might define a ``_part.serif`` glyph component with
+"left width" and "right width" axes, and for each master in the font,
+define layers for the ``_part.serif`` at some default left width and
+right width, one with an extended left width, and one with an extended
+right width, and use the "smart components settings" to assign locations
+to these additional layers. (Unlike a full interpolation model, the
+locations of smart component layers can only be at the axis extremes.)
+
+We handle smart components by decomposing them and then applying a standard
+OpenType interpolation model to adjust the node positions.
+"""
+
+from enum import IntEnum
+
+# We're going to use pickle/unpickle to copy the node objects because
+# it's considerably faster than copy.deepcopy
+import pickle
+
+from fontTools.varLib.models import VariationModel, normalizeValue
+from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates
+
+from glyphsLib.classes import GSLayer
+
+
+# smartComponentPoleMapping returns 1 for bottom of axis and 2 for top.
+class Pole(IntEnum):
+    MIN = 1
+    MAX = 2
+
+
+# This normalizes the location of a "master" (additional smart component
+# layer). Because these are defined to be at the "poles", this is always
+# 0.0, 1.0 or -1.0. But working out which it should be is slightly tricky:
+# the axes don't define their own defaults, but the location of the
+# default layer of the glyph tells you whether the default value of the
+# axis is at the top or the bottom.
+def normalized_location(layer, base_layer):
+    loc = {}
+    for axis_name, current_value in layer.smartComponentPoleMapping.items():
+        base_value = base_layer.smartComponentPoleMapping[axis_name]
+        if current_value == base_value:
+            loc[axis_name] = 0.0
+        elif base_value == Pole.MIN and current_value == Pole.MAX:
+            loc[axis_name] = 1.0
+        elif base_value == Pole.MAX and current_value == Pole.MIN:
+            loc[axis_name] = -1.0
+        else:
+            raise ValueError(
+                f"Strange axis mapping for axis {axis_name} in smart layer {base_layer}"
+            )
+    return loc
+
+
+def variation_model(glyph, smart_layers):
+    master_locations = [normalized_location(l, smart_layers[0]) for l in smart_layers]
+    axis_order = [ax.name for ax in glyph.smartComponentAxes]
+    return VariationModel(master_locations, axisOrder=axis_order, extrapolate=True)
+
+
+# Two slightly horrible functions for turning a GSLayer into a
+# GlyphCoordinates object and back again.
+def get_coordinates(layer):
+    gc = GlyphCoordinates([])
+    for path in layer.paths:
+        gc.extend(
+            GlyphCoordinates([(pt.position.x, pt.position.y) for pt in path.nodes])
+        )
+    return gc
+
+
+def set_coordinates(layer, coords):
+    counter = 0
+    for path in layer.paths:
+        for node in path.nodes:
+            node.position.x, node.position.y = coords[counter]
+            counter += 1
+
+
+def to_ufo_smart_component(self, layer, component, pen):
+    # Find the GSGlyph that is being used as a component by this GSComponent
+    root = component.component
+
+    masters = [l for l in root.layers if l.smartComponentPoleMapping]
+    if layer.associatedMasterId:
+        # Each master in the font can have its own set of smart component
+        # "master layers", so we need to filter by those smart components
+        # which are in the same font master as the current one
+        masters = [
+            l for l in masters if l.associatedMasterId == layer.associatedMasterId
+        ]
+    if not masters:
+        raise ValueError(
+            "Could not find any masters for the smart component %s used in %s"
+            % (root.name, layer.name)
+        )
+    model = variation_model(root, masters)
+
+    # Determine the normalized location of the interpolant within the
+    # mini-designspace, remembering that we have to work out where the
+    # default value is by looking at the first "master"
+    axes_tuples = {}
+    for ax in root.smartComponentAxes:
+        if masters[0].smartComponentPoleMapping[ax.name] == Pole.MIN:
+            defaultValue = ax.bottomValue
+        else:
+            defaultValue = ax.topValue
+        axes_tuples[ax.name] = (ax.bottomValue, defaultValue, ax.topValue)
+    normalized_location = {
+        name: normalizeValue(value, axes_tuples[name], extrapolate=True)
+        for name, value in component.smartComponentValues.items()
+    }
+    coordinates = [get_coordinates(l) for l in masters]
+    new_coords = model.interpolateFromMasters(normalized_location, coordinates)
+
+    # Decompose by creating a new layer, copying its shapes and applying
+    # the new coordinates
+    new_layer = GSLayer()
+    new_layer._shapes = pickle.loads(pickle.dumps(masters[0]._shapes))
+    set_coordinates(new_layer, new_coords)
+
+    # Don't forget that the GSComponent might also be transformed, so
+    # we need to apply that transformation to the new layer as well
+    if component.transform:
+        for p in new_layer.paths:
+            p.applyTransform(component.transform)
+
+    # And we are done
+    new_layer.drawPoints(pen)
diff --git a/requirements-dev.in b/requirements-dev.in
index 07bf1058..827f6777 100644
--- a/requirements-dev.in
+++ b/requirements-dev.in
@@ -6,6 +6,7 @@ pytest>=2.8
 pytest-randomly
 pytest-xdist
 xmldiff>=2.2
+fonttools[ufo,unicode] >= 4.38.0
 # extras
 ufoNormalizer>=0.3.2
 defcon>=0.6.0
diff --git a/requirements-dev.txt b/requirements-dev.txt
index e4b8ee8b..75bd7a53 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -1,5 +1,5 @@
 #
-# This file is autogenerated by pip-compile with python 3.7
+# This file is autogenerated by pip-compile with python 3.9
 # To update, run:
 #
 #    pip-compile requirements-dev.in
@@ -32,8 +32,9 @@ flake8==4.0.1
     #   flake8-bugbear
 flake8-bugbear==22.3.23
     # via -r requirements-dev.in
-fonttools[ufo,unicode]==4.34.4
+fonttools[ufo,unicode]==4.38.0
     # via
+    #   -r requirements-dev.in
     #   booleanoperations
     #   cffsubr
     #   cu2qu
@@ -42,12 +43,7 @@ fonttools[ufo,unicode]==4.34.4
 fs==2.4.15
     # via fonttools
 importlib-metadata==4.2.0
-    # via
-    #   click
-    #   flake8
-    #   pluggy
-    #   pytest
-    #   pytest-randomly
+    # via pytest-randomly
 iniconfig==1.1.1
     # via pytest
 lxml==4.8.0
@@ -100,12 +96,8 @@ tomli==2.0.1
     # via
     #   black
     #   pytest
-typed-ast==1.5.3
-    # via black
 typing-extensions==4.2.0
-    # via
-    #   black
-    #   importlib-metadata
+    # via black
 ufo2ft==2.27.0
     # via -r requirements-dev.in
 ufonormalizer==0.6.1
diff --git a/requirements.txt b/requirements.txt
index d9cba3c4..8428bb0a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,5 @@
 #
-# This file is autogenerated by pip-compile with python 3.7
+# This file is autogenerated by pip-compile with python 3.9
 # To update, run:
 #
 #    pip-compile setup.cfg
@@ -8,7 +8,7 @@ appdirs==1.4.4
     # via fs
 attrs==21.4.0
     # via ufolib2
-fonttools[ufo,unicode]==4.34.4
+fonttools[ufo,unicode]==4.38.0
     # via
     #   glyphsLib (setup.cfg)
     #   ufolib2
@@ -20,8 +20,6 @@ pytz==2022.1
     # via fs
 six==1.16.0
     # via fs
-typing-extensions==4.2.0
-    # via ufolib2
 ufolib2==0.13.1
     # via glyphsLib (setup.cfg)
 unicodedata2==14.0.0
diff --git a/setup.cfg b/setup.cfg
index a9efd3ee..e9e6f5a1 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -32,7 +32,7 @@ setup_requires =
     setuptools_scm
 install_requires =
     ufoLib2 >= 0.6.2
-    fonttools[ufo,unicode] >= 4.33.0
+    fonttools[ufo,unicode] >= 4.38.0
     openstep-plist >= 0.3.0
 
 [options.extras_require]


Test patch:
diff --git a/tests/builder/axes_test.py b/tests/builder/axes_test.py
index 99849293..4fd48915 100644
--- a/tests/builder/axes_test.py
+++ b/tests/builder/axes_test.py
@@ -20,7 +20,7 @@ import pytest
 
 from fontTools import designspaceLib
 from glyphsLib import to_glyphs, to_designspace, to_ufos
-from glyphsLib.classes import GSFont, GSFontMaster, GSAxis
+from glyphsLib.classes import GSFont, GSFontMaster, GSAxis, GSInstance
 from glyphsLib.builder.axes import _is_subset_of_default_axes, get_regular_master
 
 """
@@ -228,6 +228,255 @@ def test_mapping_is_same_regardless_of_axes_custom_parameter(ufo_module):
     assert doc.axes[0].map == []
 
 
+def test_mapping_using_axis_location_custom_parameter_on_instances(ufo_module):
+    # https://github.com/googlefonts/glyphsLib/issues/714
+    # https://github.com/googlefonts/glyphsLib/pull/810
+
+    font = to_glyphs(
+        [ufo_module.Font(), ufo_module.Font(), ufo_module.Font(), ufo_module.Font()]
+    )
+
+    origin_id = "95FB0C11-C828-4064-8966-34220AA4D426"
+    font.customParameters["Axes"] = [
+        {"Name": "Weight", "Tag": "wght"},
+        {"Name": "Width", "Tag": "wdth"},
+    ]
+
+    # Add masters
+
+    font.masters[0].name = "Regular"
+    font.masters[0].id = origin_id
+    font.masters[0].weightValue = 72
+    font.masters[0].widthValue = 448
+    font.masters[0].customParameters["Axis Location"] = [
+        {"Axis": "Weight", "Location": 400},
+        {"Axis": "Width", "Location": 100},
+    ]
+    font.masters[1].name = "Bold"
+    font.masters[1].weightValue = 112
+    font.masters[1].widthValue = 448
+    font.masters[1].customParameters["Axis Location"] = [
+        {"Axis": "Weight", "Location": 700},
+        {"Axis": "Width", "Location": 100},
+    ]
+    font.masters[2].name = "Thin"
+    font.masters[2].weightValue = 48
+    font.masters[2].widthValue = 448
+    font.masters[2].customParameters["Axis Location"] = [
+        {"Axis": "Weight", "Location": 200},
+        {"Axis": "Width", "Location": 100},
+    ]
+    font.masters[3].name = "Cd Regular"
+    font.masters[3].weightValue = 72
+    font.masters[3].widthValue = 224
+    font.masters[3].customParameters["Axis Location"] = [
+        {"Axis": "Weight", "Location": 400},
+        {"Axis": "Width", "Location": 50},
+    ]
+
+    font.customParameters["Variable Font Origin"] = origin_id
+
+    # Add some instances with mappings
+
+    font.instances = [
+        GSInstance(),
+        GSInstance(),
+        GSInstance(),
+        GSInstance(),
+        GSInstance(),
+        GSInstance(),
+    ]
+
+    font.instances[0].name = "Thin"
+    font.instances[0].weightValue = 48
+    font.instances[0].widthValue = 448
+    font.instances[0].customParameters["Axis Location"] = [
+        {"Axis": "Weight", "Location": 200},
+        {"Axis": "Width", "Location": 100},
+    ]
+    font.instances[1].name = "Light"
+    font.instances[1].weightValue = 62
+    font.instances[1].widthValue = 448
+    font.instances[1].customParameters["Axis Location"] = [
+        {"Axis": "Weight", "Location": 300},
+        {"Axis": "Width", "Location": 100},
+    ]
+    font.instances[2].name = "Regular"
+    font.instances[2].weightValue = 72
+    font.instances[2].widthValue = 448
+    font.instances[2].customParameters["Axis Location"] = [
+        {"Axis": "Weight", "Location": 400},
+        {"Axis": "Width", "Location": 100},
+    ]
+    font.instances[3].name = "Medium"
+    font.instances[3].weightValue = 92
+    font.instances[3].widthValue = 448
+    font.instances[3].customParameters["Axis Location"] = [
+        {"Axis": "Weight", "Location": 600},
+        {"Axis": "Width", "Location": 100},
+    ]
+    font.instances[4].name = "Cd Regular"
+    font.instances[4].weightValue = 72
+    font.instances[4].widthValue = 224
+    font.instances[4].customParameters["Axis Location"] = [
+        {"Axis": "Weight", "Location": 400},
+        {"Axis": "Width", "Location": 50},
+    ]
+    font.instances[5].name = "SCd Regular"
+    font.instances[5].weightValue = 72
+    font.instances[5].widthValue = 384
+    font.instances[5].customParameters["Axis Location"] = [
+        {"Axis": "Weight", "Location": 400},
+        {"Axis": "Width", "Location": 60},
+    ]
+
+    doc = to_designspace(font, ufo_module=ufo_module)
+    assert doc.axes[0].minimum == 200
+    assert doc.axes[0].default == 400
+    assert doc.axes[0].maximum == 700
+    assert doc.axes[0].map == [(200, 48), (300, 62), (400, 72), (600, 92), (700, 112)]
+
+    assert doc.axes[1].minimum == 50
+    assert doc.axes[1].default == 100
+    assert doc.axes[1].maximum == 100
+    assert doc.axes[1].map == [(50, 224), (60, 384), (100, 448)]
+
+
+def test_mapping_using_axis_location_cp_on_masters_none(ufo_module):
+    # https://github.com/googlefonts/glyphsLib/issues/714
+    # https://github.com/googlefonts/glyphsLib/pull/810
+
+    # When masters have no or disabled Axis Location CP, the ones on the
+    # instances should still be evaluated.
+
+    font = to_glyphs(
+        [ufo_module.Font(), ufo_module.Font(), ufo_module.Font(), ufo_module.Font()]
+    )
+
+    font.customParameters["Axes"] = [
+        {"Name": "Weight", "Tag": "wght"},
+        {"Name": "Width", "Tag": "wdth"},
+    ]
+
+    # Add masters
+
+    font.masters[0].name = "Regular"
+    font.masters[0].weightValue = 72
+    font.masters[0].widthValue = 448
+
+    font.masters[1].name = "Bold"
+    font.masters[1].weightValue = 112
+    font.masters[1].widthValue = 448
+
+    font.masters[2].name = "Thin"
+    font.masters[2].weightValue = 48
+    font.masters[2].widthValue = 448
+
+    font.masters[3].name = "Cd Regular"
+    font.masters[3].weightValue = 72
+    font.masters[3].widthValue = 224
+
+    font.instances = [GSInstance(), GSInstance(), GSInstance()]
+
+    font.instances[0].name = "Regular"
+    font.instances[0].weightValue = 72
+    font.instances[0].widthValue = 448
+    font.instances[0].customParameters["Axis Location"] = [
+        {"Axis": "Weight", "Location": 400},
+        {"Axis": "Width", "Location": 100},
+    ]
+    font.instances[1].name = "SCd Regular"
+    font.instances[1].weightValue = 72
+    font.instances[1].widthValue = 384
+    font.instances[1].customParameters["Axis Location"] = [
+        {"Axis": "Weight", "Location": 400},
+        {"Axis": "Width", "Location": 60},
+    ]
+    font.instances[2].name = "Cd Regular"
+    font.instances[2].weightValue = 72
+    font.instances[2].widthValue = 224
+    font.instances[2].customParameters["Axis Location"] = [
+        {"Axis": "Weight", "Location": 400},
+        {"Axis": "Width", "Location": 50},
+    ]
+
+    doc = to_designspace(font, ufo_module=ufo_module)
+    assert doc.axes[0].minimum == 400
+    assert doc.axes[0].default == 400
+    assert doc.axes[0].maximum == 400
+    assert doc.axes[0].map == [(400, 72)]
+
+    assert doc.axes[1].minimum == 50
+    assert doc.axes[1].default == 100
+    assert doc.axes[1].maximum == 100
+    assert doc.axes[1].map == [(50, 224), (60, 384), (100, 448)]
+
+
+def test_mapping_using_axis_location_cp_on_instances_none(ufo_module):
+    # https://github.com/googlefonts/glyphsLib/issues/714
+    # https://github.com/googlefonts/glyphsLib/pull/810
+
+    # When all masters have Axis Location CP, non-"Axis Location" instance
+    # mappings should be ignored.
+
+    font = to_glyphs(
+        [ufo_module.Font(), ufo_module.Font(), ufo_module.Font(), ufo_module.Font()]
+    )
+
+    font.customParameters["Axes"] = [
+        {"Name": "Weight", "Tag": "wght"},
+        {"Name": "Width", "Tag": "wdth"},
+    ]
+
+    # Add masters
+
+    font.masters[0].name = "Regular"
+    font.masters[0].weightValue = 72
+    font.masters[0].widthValue = 448
+    font.masters[0].customParameters["Axis Location"] = [
+        {"Axis": "Weight", "Location": 400},
+        {"Axis": "Width", "Location": 100},
+    ]
+    font.masters[1].name = "Bold"
+    font.masters[1].weightValue = 112
+    font.masters[1].widthValue = 448
+    font.masters[1].customParameters["Axis Location"] = [
+        {"Axis": "Weight", "Location": 700},
+        {"Axis": "Width", "Location": 100},
+    ]
+    font.masters[2].name = "Thin"
+    font.masters[2].weightValue = 48
+    font.masters[2].widthValue = 448
+    font.masters[2].customParameters["Axis Location"] = [
+        {"Axis": "Weight", "Location": 200},
+        {"Axis": "Width", "Location": 100},
+    ]
+    font.masters[3].name = "Cd Regular"
+    font.masters[3].weightValue = 72
+    font.masters[3].widthValue = 224
+    font.masters[3].customParameters["Axis Location"] = [
+        {"Axis": "Weight", "Location": 400},
+        {"Axis": "Width", "Location": 50},
+    ]
+
+    font.instances = [GSInstance()]
+
+    font.instances[0].name = "SCd Regular"
+    font.instances[0].weightValue = 72
+    font.instances[0].widthValue = 384
+
+    doc = to_designspace(font, ufo_module=ufo_module)
+    assert doc.axes[0].minimum == 200
+    assert doc.axes[0].default == 400
+    assert doc.axes[0].maximum == 700
+    assert doc.axes[0].map == [(200, 48), (400, 72), (700, 112)]
+
+    assert doc.axes[1].minimum == 50
+    assert doc.axes[1].default == 100
+    assert doc.axes[1].maximum == 100
+    assert doc.axes[1].map == [(50, 224), (100, 448)]
+
+
 def test_custom_parameter_vfo_current():
     """Tests get_regular_master when 'Variable Font Origin' custom parameter name
     is used with master set to 'Regular Text'.  This is the current default
diff --git a/tests/smart_components_test.py b/tests/smart_components_test.py
new file mode 100644
index 00000000..3fa98d09
--- /dev/null
+++ b/tests/smart_components_test.py
@@ -0,0 +1,175 @@
+# coding=UTF-8
+#
+# Copyright 2016 Google Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import pytest
+
+from glyphsLib import to_ufos
+from glyphsLib.classes import (
+    GSFont,
+    GSFontMaster,
+    GSGlyph,
+    GSLayer,
+    GSPath,
+    GSNode,
+    GSSmartComponentAxis,
+    GSComponent,
+)
+
+
+# https://glyphsapp.com/tutorials/smart-components
+
+
+@pytest.fixture
+def smart_font():
+    """Make a font with a smart component in the shape of a rectangle."""
+    font = GSFont()
+    master = GSFontMaster()
+    font.masters.append(master)
+
+    rectangle = GSGlyph()
+    rectangle.name = "_part.rectangle"
+    # Could also be rectangle.name = '_smart.rectangle'
+    font.glyphs.append(rectangle)
+
+    # Three axes
+    width = GSSmartComponentAxis()
+    width.name = "Width"
+    # This one is easy 0-1
+    width.bottomValue = 0.0
+    width.topValue = 1.0
+    rectangle.smartComponentAxes.append(width)
+
+    height = GSSmartComponentAxis()
+    height.name = "Height"
+    # This one is off the origin
+    height.bottomValue = 100
+    height.topValue = 500
+    rectangle.smartComponentAxes.append(height)
+
+    shift = GSSmartComponentAxis()
+    shift.name = "Shift"
+    # This one has negative values
+    shift.bottomValue = -100
+    shift.topValue = 0
+    rectangle.smartComponentAxes.append(shift)
+
+    # Four masters
+    regular = GSLayer()
+    regular.layerId = font.masters[0].id
+    regular.associatedMasterId = font.masters[0].id
+    regular.width = 300
+    rectangle.layers.append(regular)
+    regular.paths.append(rectangle_path(100, 100, 100, 100))
+    regular.smartComponentPoleMapping["Width"] = 1  # 1 is bottom pole
+    regular.smartComponentPoleMapping["Height"] = 1
+    regular.smartComponentPoleMapping["Shift"] = 2  # 2 is the top pole
+
+    wide = GSLayer()
+    wide.name = "Wide"
+    wide.layerId = "wide"
+    wide.associatedMasterId = font.masters[0].id
+    wide.width = 700
+    rectangle.layers.append(wide)
+    wide.paths.append(rectangle_path(100, 100, 500, 100))
+    wide.smartComponentPoleMapping["Width"] = 2
+    wide.smartComponentPoleMapping["Height"] = 1
+    wide.smartComponentPoleMapping["Shift"] = 2
+
+    tall = GSLayer()
+    tall.name = "Tall"
+    tall.layerId = "tall"
+    tall.associatedMasterId = font.masters[0].id
+    tall.width = 300
+    rectangle.layers.append(tall)
+    tall.paths.append(rectangle_path(100, 100, 100, 500))
+    tall.smartComponentPoleMapping["Width"] = 1
+    tall.smartComponentPoleMapping["Height"] = 2
+    tall.smartComponentPoleMapping["Shift"] = 2
+
+    shifted = GSLayer()
+    shifted.name = "Shifted"
+    shifted.layerId = "shifted"
+    shifted.associatedMasterId = font.masters[0].id
+    shifted.width = 100
+    rectangle.layers.append(shifted)
+    shifted.paths.append(rectangle_path(0, 0, 100, 100))
+    shifted.smartComponentPoleMapping["Width"] = 1
+    shifted.smartComponentPoleMapping["Height"] = 1
+    shifted.smartComponentPoleMapping["Shift"] = 1
+
+    # Also add a normal glyph in which we can instanciate the component
+    a = GSGlyph()
+    a.name = "a"
+    font.glyphs.append(a)
+    regular = GSLayer()
+    regular.layerId = font.masters[0].id
+    regular.associatedMasterId = font.masters[0].id
+    regular.width = 1000
+    a.layers.append(regular)
+
+    component = GSComponent(rectangle.name)
+    component.smartComponentValues = {}
+    regular.components.append(component)
+
+    return font
+
+
+def rectangle_path(x, y, w, h):
+    path = GSPath()
+    path.nodes.append(GSNode((x, y)))
+    path.nodes.append(GSNode((x + w, y)))
+    path.nodes.append(GSNode((x + w, y + h)))
+    path.nodes.append(GSNode((x, y + h)))
+    return path
+
+
+@pytest.mark.parametrize(
+    "values,expected",
+    [
+        # Eight corners
+        ({"Width": 0, "Height": 100, "Shift": 0}, (100, 100, 100, 100)),
+        ({"Width": 1, "Height": 100, "Shift": 0}, (100, 100, 500, 100)),
+        ({"Width": 0, "Height": 500, "Shift": 0}, (100, 100, 100, 500)),
+        ({"Width": 1, "Height": 500, "Shift": 0}, (100, 100, 500, 500)),
+        ({"Width": 0, "Height": 100, "Shift": -100}, (0, 0, 100, 100)),
+        ({"Width": 1, "Height": 100, "Shift": -100}, (0, 0, 500, 100)),
+        ({"Width": 0, "Height": 500, "Shift": -100}, (0, 0, 100, 500)),
+        ({"Width": 1, "Height": 500, "Shift": -100}, (0, 0, 500, 500)),
+        # Some points in the middle
+        ({"Width": 0.5, "Height": 300, "Shift": -50}, (50, 50, 300, 300)),
+        # Extrapolation
+        ({"Width": 0, "Height": 800, "Shift": 0}, (100, 100, 100, 800)),
+    ],
+)
+def test_smart_component_regular(values, expected, smart_font):
+    component = smart_font.glyphs["a"].layers[0].components[0]
+    for key, value in values.items():
+        component.smartComponentValues[key] = value
+
+    (ufo,) = to_ufos(smart_font)
+
+    assert get_rectangle_data(ufo) == expected
+
+
+def get_rectangle_data(ufo, glyph_name="a"):
+    """Retrieve the results of the smart component interpolation."""
+    a = ufo[glyph_name]
+    contour = a[0]
+    left = min(node.x for node in contour)
+    right = max(node.x for node in contour)
+    top = max(node.y for node in contour)
+    bottom = min(node.y for node in contour)
+    return (left, bottom, right - left, top - bottom)