Repository: googlefonts/glyphsLib
Pull request #610 (2020-05-04 14:39:07)

Decompose background layers
This PR decomposes components used in background layers when going glyphs2ufo.

The UFO v3 and Glyphs data model have incompatible component reference semantics. UFO components always point to a glyph in the same layer, Glyphs components in a ...:

- master layer: point to glyphs in the same master layer, like in the UFO format.
- non-master layer: point to glyphs in the layer with the same layer name and fall back to glyphs in the associated master layer

Limit to background layers for now because functional layers like bracket and brace layers can contain components, too, and need special but too time-consuming handling. Non-functional layers like the copies you can make in Glyphs' UI with the "+" in the layer pane are likewise ignored. Bracket layers are already flattened in UFOs.

Closes https://github.com/googlefonts/glyphsLib/issues/449.
Partially tackles https://github.com/googlefonts/glyphsLib/issues/375.


Patch:
diff --git a/Lib/glyphsLib/builder/builders.py b/Lib/glyphsLib/builder/builders.py
index 9168edb1..8d4b440e 100644
--- a/Lib/glyphsLib/builder/builders.py
+++ b/Lib/glyphsLib/builder/builders.py
@@ -19,6 +19,7 @@ import logging
 import os
 import re
 from textwrap import dedent
+from typing import Dict
 
 from fontTools import designspaceLib
 
@@ -117,6 +118,10 @@ class UFOBuilder(_LoggerMixin):
         # indexed by master ID, the same order as masters in the source GSFont.
         self._sources = OrderedDict()
 
+        # A cache for mappings of layer IDs to mappings of glyph names to Glyphs layers,
+        # for passing into pens as glyph sets.
+        self._glyph_sets: Dict[str, Dict[str, classes.GSLayer]] = {}
+
         # The designSpaceDocument object that will be built.
         # The sources will be built in any case, at the same time that we build
         # the master UFOs, when the user requests them.
diff --git a/Lib/glyphsLib/builder/components.py b/Lib/glyphsLib/builder/components.py
index 14c5339d..3cd09464 100644
--- a/Lib/glyphsLib/builder/components.py
+++ b/Lib/glyphsLib/builder/components.py
@@ -15,6 +15,8 @@
 
 import logging
 
+from fontTools.pens.recordingPen import DecomposingRecordingPen
+from glyphsLib.classes import GSBackgroundLayer
 from glyphsLib.types import Transform
 
 from .constants import GLYPHS_PREFIX, COMPONENT_INFO_KEY
@@ -24,8 +26,25 @@ logger = logging.getLogger(__name__)
 
 def to_ufo_components(self, ufo_glyph, layer):
     """Draw .glyphs components onto a pen, adding them to the parent glyph."""
-    pen = ufo_glyph.getPointPen()
 
+    # NOTE: The UFO v3 and Glyphs data model have incompatible component reference
+    # semantics. UFO components always point to a glyph in the same layer, Glyphs
+    # components in a ...:
+    #  - master layer: point to glyphs in the same master layer.
+    #  - non-master layer: point to glyphs in the layer with the same name and fall
+    #    back to glyphs in the associated master layer
+    # There are some valid use-cases for components in non-master layers, and doing it
+    # thoroughly correctly is time-consuming, so we're decomposing just the background
+    # layer components as a band-aid.
+    if layer.components and isinstance(layer, GSBackgroundLayer):
+        logger.warning(
+            f"Glyph '{ufo_glyph.name}': All components of the background layer of "
+            f"'{layer.foreground.name}' will be decomposed."
+        )
+        to_ufo_components_background_decompose(self, ufo_glyph, layer)
+        return
+
+    pen = ufo_glyph.getPointPen()
     for index, component in enumerate(layer.components):
         pen.addComponent(component.name, component.transform)
 
@@ -51,6 +70,49 @@ def to_ufo_components(self, ufo_glyph, layer):
             ufo_glyph.lib[_lib_key(key)] = values
 
 
+def to_ufo_components_background_decompose(self, ufo_glyph, layer):
+    """Draw decomposed .glyphs background components with a pen, adding them to
+    the parent glyph."""
+
+    layer_id = layer.foreground.layerId
+    layer_master_id = layer.foreground.associatedMasterId
+
+    if layer_id in self._glyph_sets:
+        layers = self._glyph_sets[layer_id]
+    else:
+        if layer_id == layer_master_id:
+            # Is a master layer.
+            layers = self._glyph_sets[layer_id] = {
+                g.name: l
+                for g in layer.parent.parent.glyphs
+                for l in g.layers
+                if l.layerId == layer_id
+            }
+        else:
+            # Is a non-master layer.
+            layers_nonmaster = {
+                g.name: l
+                for g in layer.parent.parent.glyphs
+                for l in g.layers
+                if l.name == layer.name
+            }
+            layers_master = {
+                g.name: l
+                for g in layer.parent.parent.glyphs
+                for l in g.layers
+                if l.layerId == layer_master_id
+            }
+            layers = self._glyph_sets[layer_id] = {
+                **layers_master,
+                **layers_nonmaster,
+            }
+
+    rpen = DecomposingRecordingPen(glyphSet=layers)
+    for component in layer.components:
+        component.draw(rpen)
+    rpen.replay(ufo_glyph.getPen())
+
+
 def to_glyphs_components(self, ufo_glyph, layer):
     for comp in ufo_glyph.components:
         component = self.glyphs_module.GSComponent(comp.baseGlyph)


Test patch:
diff --git a/tests/builder/components_test.py b/tests/builder/components_test.py
new file mode 100644
index 00000000..96adcd70
--- /dev/null
+++ b/tests/builder/components_test.py
@@ -0,0 +1,37 @@
+import glyphsLib
+from glyphsLib import to_designspace
+
+
+def test_background_component_decompose(datadir):
+    font = glyphsLib.GSFont(str(datadir.join("Recursion.glyphs")))
+    ds = to_designspace(font)
+
+    for source in ds.sources:
+        for layer in source.font.layers:
+            for glyph in layer:
+                if layer.name == "Apr 27 20, 17:59" and glyph.name == "B":
+                    continue
+                assert not glyph.components
+
+    ufo_rg = ds.sources[0].font
+    assert ufo_rg.layers["public.background"]["A"].contours == ufo_rg["B"].contours
+    assert (
+        ufo_rg.layers["Apr 27 20, 17:57.background"]["A"].contours
+        == ufo_rg["B"].contours
+    )
+    assert ufo_rg.layers["public.background"]["B"].contours == ufo_rg["A"].contours
+    assert len(ufo_rg.layers["Apr 27 20, 17:59.background"]["B"].contours) == 2
+
+    assert ufo_rg.layers["Apr 27 20, 17:59"]["B"].components
+
+    ufo_bd = ds.sources[1].font
+    assert ufo_bd.layers["public.background"]["A"].contours == ufo_bd["B"].contours
+    assert (
+        ufo_bd.layers["Apr 27 20, 17:57.background"]["A"].contours
+        == ufo_bd["B"].contours
+    )
+    assert ufo_bd.layers["public.background"]["B"].contours == ufo_bd["A"].contours
+    assert (
+        ufo_bd.layers["Apr 27 20, 17:59.background"]["B"].contours
+        == ufo_bd["A"].contours
+    )
diff --git a/tests/data/Recursion.glyphs b/tests/data/Recursion.glyphs
new file mode 100644
index 00000000..de987d47
--- /dev/null
+++ b/tests/data/Recursion.glyphs
@@ -0,0 +1,302 @@
+{
+.appVersion = "1286";
+DisplayStrings = (
+A,
+B
+);
+date = "2020-04-24 16:23:39 +0000";
+familyName = "Neue Schrift";
+fontMaster = (
+{
+ascender = 800;
+capHeight = 700;
+descender = -200;
+id = "23DBA3BE-95F2-4A36-B846-28FED8CD3077";
+xHeight = 500;
+},
+{
+ascender = 800;
+capHeight = 700;
+descender = -200;
+id = "9F8BD92C-569A-4F95-BCFD-3E6F76A0188C";
+weight = Bold;
+weightValue = 700;
+xHeight = 500;
+}
+);
+glyphs = (
+{
+glyphname = A;
+lastChange = "2020-05-04 12:35:03 +0000";
+layers = (
+{
+background = {
+components = (
+{
+name = B;
+}
+);
+};
+layerId = "23DBA3BE-95F2-4A36-B846-28FED8CD3077";
+paths = (
+{
+closed = 1;
+nodes = (
+"132 45 LINE",
+"514 45 LINE",
+"514 467 LINE",
+"132 467 LINE"
+);
+}
+);
+width = 600;
+},
+{
+background = {
+components = (
+{
+name = B;
+}
+);
+};
+layerId = "9F8BD92C-569A-4F95-BCFD-3E6F76A0188C";
+paths = (
+{
+closed = 1;
+nodes = (
+"2 45 LINE",
+"602 45 LINE",
+"602 623 LINE",
+"2 623 LINE"
+);
+}
+);
+width = 600;
+},
+{
+associatedMasterId = "23DBA3BE-95F2-4A36-B846-28FED8CD3077";
+background = {
+components = (
+{
+name = B;
+}
+);
+};
+layerId = "639681FE-76C3-4844-9666-20E7C090BF34";
+name = "Apr 27 20, 17:57";
+paths = (
+{
+closed = 1;
+nodes = (
+"132 45 LINE",
+"514 45 LINE",
+"864 607 LINE",
+"132 467 LINE"
+);
+}
+);
+width = 600;
+},
+{
+associatedMasterId = "9F8BD92C-569A-4F95-BCFD-3E6F76A0188C";
+background = {
+components = (
+{
+name = B;
+}
+);
+};
+layerId = "C373278B-1396-4D45-BE26-907F6BC8DBE4";
+name = "Apr 27 20, 17:57";
+paths = (
+{
+closed = 1;
+nodes = (
+"2 45 LINE",
+"602 45 LINE",
+"840 787 LINE",
+"2 623 LINE"
+);
+}
+);
+width = 600;
+}
+);
+unicode = 0041;
+},
+{
+glyphname = B;
+lastChange = "2020-05-04 16:29:26 +0000";
+layers = (
+{
+background = {
+components = (
+{
+name = A;
+}
+);
+};
+layerId = "23DBA3BE-95F2-4A36-B846-28FED8CD3077";
+paths = (
+{
+closed = 1;
+nodes = (
+"441 163 OFFCURVE",
+"530 249 OFFCURVE",
+"530 354 CURVE SMOOTH",
+"530 459 OFFCURVE",
+"441 545 OFFCURVE",
+"332 545 CURVE SMOOTH",
+"223 545 OFFCURVE",
+"134 459 OFFCURVE",
+"134 354 CURVE SMOOTH",
+"134 249 OFFCURVE",
+"223 163 OFFCURVE",
+"332 163 CURVE SMOOTH"
+);
+}
+);
+width = 600;
+},
+{
+background = {
+components = (
+{
+name = A;
+}
+);
+};
+layerId = "9F8BD92C-569A-4F95-BCFD-3E6F76A0188C";
+paths = (
+{
+closed = 1;
+nodes = (
+"441 73 OFFCURVE",
+"598 249 OFFCURVE",
+"598 354 CURVE SMOOTH",
+"598 459 OFFCURVE",
+"441 631 OFFCURVE",
+"332 631 CURVE SMOOTH",
+"223 631 OFFCURVE",
+"12 459 OFFCURVE",
+"12 354 CURVE SMOOTH",
+"12 249 OFFCURVE",
+"223 73 OFFCURVE",
+"332 73 CURVE SMOOTH"
+);
+}
+);
+width = 600;
+},
+{
+associatedMasterId = "23DBA3BE-95F2-4A36-B846-28FED8CD3077";
+background = {
+components = (
+{
+name = A;
+}
+);
+paths = (
+{
+closed = 1;
+nodes = (
+"752 843 OFFCURVE",
+"1010 852 OFFCURVE",
+"1010 864 CURVE SMOOTH",
+"1010 876 OFFCURVE",
+"752 885 OFFCURVE",
+"434 885 CURVE SMOOTH",
+"116 885 OFFCURVE",
+"-142 876 OFFCURVE",
+"-142 864 CURVE SMOOTH",
+"-142 852 OFFCURVE",
+"116 843 OFFCURVE",
+"434 843 CURVE SMOOTH"
+);
+}
+);
+};
+components = (
+{
+name = A;
+transform = "{1, 0, 0, 1, 450, -430}";
+}
+);
+layerId = "DB4D7D04-C02D-48DE-811E-03AA03052DD2";
+name = "Apr 27 20, 17:59";
+paths = (
+{
+closed = 1;
+nodes = (
+"441 163 OFFCURVE",
+"774 270 OFFCURVE",
+"774 375 CURVE SMOOTH",
+"774 480 OFFCURVE",
+"441 545 OFFCURVE",
+"332 545 CURVE SMOOTH",
+"223 545 OFFCURVE",
+"134 459 OFFCURVE",
+"134 354 CURVE SMOOTH",
+"134 249 OFFCURVE",
+"223 163 OFFCURVE",
+"332 163 CURVE SMOOTH"
+);
+}
+);
+width = 600;
+},
+{
+associatedMasterId = "9F8BD92C-569A-4F95-BCFD-3E6F76A0188C";
+background = {
+components = (
+{
+name = A;
+}
+);
+};
+layerId = "788B28BA-7E6B-4B58-A820-6081359D0AF0";
+name = "Apr 27 20, 17:59";
+paths = (
+{
+closed = 1;
+nodes = (
+"441 73 OFFCURVE",
+"824 249 OFFCURVE",
+"824 354 CURVE SMOOTH",
+"824 459 OFFCURVE",
+"441 631 OFFCURVE",
+"332 631 CURVE SMOOTH",
+"223 631 OFFCURVE",
+"12 459 OFFCURVE",
+"12 354 CURVE SMOOTH",
+"12 249 OFFCURVE",
+"223 73 OFFCURVE",
+"332 73 CURVE SMOOTH"
+);
+}
+);
+width = 600;
+}
+);
+unicode = 0042;
+},
+{
+glyphname = space;
+lastChange = "2020-04-27 16:57:58 +0000";
+layers = (
+{
+layerId = "23DBA3BE-95F2-4A36-B846-28FED8CD3077";
+width = 600;
+},
+{
+layerId = "9F8BD92C-569A-4F95-BCFD-3E6F76A0188C";
+width = 600;
+}
+);
+unicode = 0020;
+}
+);
+unitsPerEm = 1000;
+versionMajor = 1;
+versionMinor = 0;
+}