Repository: googlefonts/glyphsLib
Pull request #896 (2023-04-28 17:21:01)

Support glyph property tokens
See Glyphs 3 handbook.

Currently only width and anchors properties work, if our GSLayer starts supporting LSB, RSB, TSB, and BSB, they should magically work here as well.


Patch:
diff --git a/Lib/glyphsLib/builder/tokens.py b/Lib/glyphsLib/builder/tokens.py
index 70705e05..23980373 100644
--- a/Lib/glyphsLib/builder/tokens.py
+++ b/Lib/glyphsLib/builder/tokens.py
@@ -13,6 +13,7 @@ class TokenExpander:
 
     number_token_re = r"\$\{([^}]+)\}"
     glyph_predicate_re = r"\$\[([^\]]+)\]"
+    glyph_property_re = r"\$\{([^}]+:[^}]+)\}"
     bare_number_value_re = r"\$(\w+)\b"
 
     def __init__(self, font, master):
@@ -38,6 +39,7 @@ class TokenExpander:
 
     def parse_token(self):
         for (regexp, parser) in [
+            (self.glyph_property_re, self.parse_glyph_property),
             (self.number_token_re, self.parse_number_token),
             (self.glyph_predicate_re, self.parse_glyph_predicate),
             (self.bare_number_value_re, self.parse_bare_number_value),
@@ -67,7 +69,7 @@ class TokenExpander:
         # number tokens
         return str(value)
 
-    def parse_number_token(self, token):
+    def parse_number_token(self, token, layer=None):
         # These things can contain: number tokens, literal numbers, +-*/, space
         # We will also allow ()
         expression = ""
@@ -77,19 +79,34 @@ class TokenExpander:
                 expression += token[0]
                 token = token[1:]
                 continue
-            # Assume it's a number token
-            m = re.match(r"^(\w+)", token)
+            # Assume it's a property or a number token
+            if layer is not None:
+                m = re.match(r"^([a-zA-Z0-9_.]+)", token)
+            else:
+                m = re.match(r"^(\w+)", token)
             if not m:
                 raise ValueError(
                     "Unknown character %s in number token '%s' at position %i"
                     % (token[0], originaltoken, self.position)
                 )
-            expression += self.parse_bare_number_value(m[1])
+            if layer is not None:
+                try:
+                    expression += self._get_value_for_layer(layer, m[1])
+                except ValueError:
+                    expression += self.parse_bare_number_value(m[1])
+            else:
+                expression += self.parse_bare_number_value(m[1])
             token = token[len(m[0]) :]
         # This expression is now just numbers and operators - safe to eval,
         # but needs to be an integer
         return "%i" % eval(expression)
 
+    def parse_glyph_property(self, token):
+        glyphname, token = token.split(":")
+        glyph = self.font.glyphs[glyphname]
+        layer = glyph.layers[self.master.id]
+        return self.parse_number_token(token, layer)
+
     def parse_glyph_predicate(self, token):
         self.originaltoken = token
         self.glyph_predicate = token  # Use a little subparser thing here
@@ -252,6 +269,31 @@ class TokenExpander:
             return m[1]
         self._parse_error_in_predicate("value")
 
+    def _get_value_for_anchor(self, layer, value):
+        _, anchorname, attr = value.split(".")
+        anchor = layer.anchors[anchorname]
+        if anchor is None:
+            raise ValueError(
+                "Unknown anchor '%s' at position %i" % (anchorname, self.position)
+            )
+        try:
+            return str(getattr(anchor.position, attr))
+        except AttributeError as e:
+            raise ValueError(
+                "Unknown anchor attribute '%s' at position %i" % (attr, self.position)
+            ) from e
+
+    def _get_value_for_layer(self, layer, value):
+        if value.startswith("anchors."):
+            return self._get_value_for_anchor(layer, value)
+
+        try:
+            return str(getattr(layer, value))
+        except AttributeError as e:
+            raise ValueError(
+                "Unknown glyph property '%s' at position %i" % (value, self.position)
+            ) from e
+
     def _get_value_for_glyph(self, g, value):
         try:
             return getattr(g, value)


Test patch:
diff --git a/tests/data/TokenTest.glyphs b/tests/data/TokenTest.glyphs
index 8b330c9e..856b2bd0 100644
--- a/tests/data/TokenTest.glyphs
+++ b/tests/data/TokenTest.glyphs
@@ -19,7 +19,9 @@ name = Languagesystems;
 );
 features = (
 {
-code = "pos A A.sc ${padding*2};";
+code = "pos A A.sc ${padding*2};
+
+pos A Sacute ${Sacute:width/2};";
 tag = dist;
 }
 );
@@ -82,13 +84,41 @@ glyphs = (
 glyphname = A;
 kernLeft = H;
 kernRight = L;
-lastChange = "2021-01-14 12:22:21 +0000";
+lastChange = "2023-04-28 17:12:34 +0000";
 layers = (
 {
+anchors = (
+{
+name = bottom;
+pos = (300,0);
+},
+{
+name = ogonek;
+pos = (540,10);
+},
+{
+name = top;
+pos = (300,700);
+}
+);
 layerId = m01;
 width = 600;
 },
 {
+anchors = (
+{
+name = bottom;
+pos = (300,0);
+},
+{
+name = ogonek;
+pos = (540,10);
+},
+{
+name = top;
+pos = (300,700);
+}
+);
 layerId = "11119B89-ADF6-486B-9172-E51437C3592A";
 width = 600;
 }
@@ -114,11 +144,11 @@ note = "I love it!";
 {
 category = Separator;
 glyphname = Sacute;
-lastChange = "2021-01-14 12:22:48 +0000";
+lastChange = "2023-04-28 17:52:32 +0000";
 layers = (
 {
 layerId = m01;
-width = 600;
+width = 300;
 },
 {
 layerId = "11119B89-ADF6-486B-9172-E51437C3592A";
diff --git a/tests/tokens_test.py b/tests/tokens_test.py
index 5402d9f9..fd30c234 100644
--- a/tests/tokens_test.py
+++ b/tests/tokens_test.py
@@ -37,6 +37,26 @@ expander = TokenExpander(TESTFONT, master)
         (r"pos a ${padding + padding} b;", "pos a 500 b;", False),
         (r"pos a ${padding + (padding/2)} b;", "pos a 375 b;", False),
         ("pos a $xxx b;", "", True),
+        (r"pos A ${A.sc:width};", "pos A 600;", False),
+        (r"pos A ${A:width+1};", "pos A 601;", False),
+        (r"pos A ${A:width*-1};", "pos A -600;", False),
+        (r"pos A -${A:width};", "pos A -600;", False),
+        (r"pos A ${A:width*10};", "pos A 6000;", False),
+        (r"pos A ${A:width/10};", "pos A 60;", False),
+        (r"pos A ${A:width-10};", "pos A 590;", False),
+        (r"pos A ${A:width+width};", "pos A 1200;", False),
+        (r"pos A ${A:width+padding};", "pos A 850;", False),
+        (r"pos A ${A:length};", "", True),
+        # (r"pos A ${A:LSB}", "pos A 600;", False),
+        (r"pos A ${A:anchors.top.x};", "pos A 300;", False),
+        (r"pos A ${A:anchors.ogonek.y};", "pos A 10;", False),
+        (
+            r"pos A <${A:anchors.top.x} ${A:anchors.top.y} 0 0>;",
+            "pos A <300 700 0 0>;",
+            False,
+        ),
+        (r"pos A ${A:anchors.ogonek.z};", "", True),
+        (r"pos A ${A:anchors.center.x};", "", True),
         # Tests from Glyphs tutorial
         (
             "$[name endswith '.sc']",
@@ -103,3 +123,6 @@ def test_end_to_end():
 
     assert "pos A A.sc 100" in ufos[0].features.text
     assert "pos A A.sc 500" in ufos[1].features.text
+
+    assert "pos A Sacute 150" in ufos[0].features.text
+    assert "pos A Sacute 300" in ufos[1].features.text