Repository: fonttools/fonttools

Issue:
[feaLib] Bug in ChainContextPos compilation?
I have this fea snippet:
```
@uppercase = [A B C];
@shift = [at hyphen];
feature calt {
    pos @uppercase @shift' @uppercase <0 200 0 0>;
    pos @uppercase space @shift' @uppercase <0 200 0 0>;
    pos @uppercase @shift' space @uppercase <0 200 0 0>;
    pos @uppercase space @shift' space @uppercase <0 200 0 0>;
} calt;
```
The resulting font works for the first two cases (eg. `/A/hyphen/A` and `/A/space/hyphen/A`), but not for the latter two (eg. `/A/hyphen/space/A` and `/A/space/hyphen/space/A`).

Good:
```
$ hb-shape chaincontextpos_bug.ttf "A-A"
[gid4=0+1000|gid3=1@0,200+1000|gid4=2+1000]
$ hb-shape chaincontextpos_bug.ttf "A -A"
[gid4=0+1000|gid1=1+1000|gid3=2@0,200+1000|gid4=3+1000]
```
Not good:
```
$ hb-shape chaincontextpos_bug.ttf "A- A"
[gid4=0+1000|gid3=1+1000|gid1=2+1000|gid4=3+1000]
$ hb-shape chaincontextpos_bug.ttf "A - A"
[gid4=0+1000|gid1=1+1000|gid3=2+1000|gid1=3+1000|gid4=4+1000]
```

In the TTX I see that the first two `ChainPosClassSet` items do have a `PosLookupRecord`, but the latter two do not.

The same snippet compiled with makeotf results in a correctly working font.

The attached archive contains a font, ttx dump and a script that creates the font.


Discussion:
> The same snippet compiled with makeotf results in a correctly working font.

FWIW, with feaLib it compiles to `<ChainContextPos index="0" Format="2">`, and with makeotf it compiles to `<ChainContextPos index="0" Format="3">`
The problem happens at the parser-level, and I just found a work-around (write the value record right after the affected input glyph):

```
@uppercase = [A B C];
@shift = [at hyphen];
feature calt {
    pos @uppercase @shift' <0 200 0 0> @uppercase;
    pos @uppercase space @shift' <0 200 0 0> @uppercase;
    pos @uppercase @shift' <0 200 0 0> space @uppercase;
    pos @uppercase space @shift' <0 200 0 0> space @uppercase;
} calt;
```
are the two notations meant to be equivalent?

> are the two notations meant to be equivalent?

Good question. They seem to be in makeotf, but I can't find conformation in the spec (but I didn't look yet very hard).
I'm fairly sure this is the relevant bit of code:
https://github.com/fonttools/fonttools/blob/9c18e2a07d10d61dd377cca285bbd11dc188d1b0/Lib/fontTools/feaLib/parser.py#L475-L484
This is the makeotf error message if you do it wrong:
```
makeotfexe [ERROR] <SourceSansPro-Master0> Positioning values are allowed only in the marked glyph sequence, or after the final glyph node when only one glyph node is marked. [features 2]
```

The shortest reproducer I found is this:
```
feature aaaa {
    pos a' b c <0 200 0 0>;
} aaaa;
```
While this one works:
```
feature aaaa {
    pos a' b <0 200 0 0>;
} aaaa;
```
So more than one lookahead glyph/class makes it not work.

Resolving patch:
diff --git a/Lib/fontTools/feaLib/parser.py b/Lib/fontTools/feaLib/parser.py
--- a/Lib/fontTools/feaLib/parser.py
+++ b/Lib/fontTools/feaLib/parser.py
@@ -473,14 +473,38 @@ def parse_glyph_pattern_(self, vertical):
             assert lookups == []
             return ([], prefix, [None] * len(prefix), values, [], hasMarks)
         else:
-            assert not any(values[: len(prefix)]), values
-            format1 = values[len(prefix) :][: len(glyphs)]
-            format2 = values[(len(prefix) + len(glyphs)) :][: len(suffix)]
-            values = (
-                format2
-                if format2 and isinstance(format2[0], self.ast.ValueRecord)
-                else format1
-            )
+            if any(values[: len(prefix)]):
+                raise FeatureLibError(
+                    "Positioning cannot be applied in the bactrack glyph sequence, "
+                    "before the marked glyph sequence.",
+                    self.cur_token_location_
+                )
+            marked_values = values[len(prefix) : len(prefix) + len(glyphs)]
+            if any(marked_values):
+                if any(values[len(prefix) + len(glyphs) :]):
+                    raise FeatureLibError(
+                        "Positioning values are allowed only in the marked glyph "
+                        "sequence, or after the final glyph node when only one glyph "
+                        "node is marked.",
+                        self.cur_token_location_
+                    )
+                values = marked_values
+            elif values and values[-1]:
+                if len(glyphs) > 1 or any(values[:-1]):
+                    raise FeatureLibError(
+                        "Positioning values are allowed only in the marked glyph "
+                        "sequence, or after the final glyph node when only one glyph "
+                        "node is marked.",
+                        self.cur_token_location_
+                    )
+                values = values[-1:]
+            elif any(values):
+                raise FeatureLibError(
+                    "Positioning values are allowed only in the marked glyph "
+                    "sequence, or after the final glyph node when only one glyph "
+                    "node is marked.",
+                    self.cur_token_location_
+                )
             return (prefix, glyphs, lookups, values, suffix, hasMarks)
 
     def parse_chain_context_(self):


Test patch:
diff --git a/Tests/feaLib/parser_test.py b/Tests/feaLib/parser_test.py
--- a/Tests/feaLib/parser_test.py
+++ b/Tests/feaLib/parser_test.py
@@ -870,6 +870,41 @@ def test_gpos_type_1_chained_special_kern_format_valuerecord_format_b(self):
         self.assertEqual(glyphstr(pos.prefix), "[A B]")
         self.assertEqual(glyphstr(pos.suffix), "comma")
 
+    def test_gpos_type_1_chained_special_kern_format_valuerecord_format_b_bug2293(self):
+        # https://github.com/fonttools/fonttools/issues/2293
+        doc = self.parse("feature kern {pos [A B] [T Y]' comma a <0 0 0 0>;} kern;")
+        pos = doc.statements[0].statements[0]
+        self.assertIsInstance(pos, ast.SinglePosStatement)
+        [(glyphs, value)] = pos.pos
+        self.assertEqual(glyphstr([glyphs]), "[T Y]")
+        self.assertEqual(value.asFea(), "<0 0 0 0>")
+        self.assertEqual(glyphstr(pos.prefix), "[A B]")
+        self.assertEqual(glyphstr(pos.suffix), "comma a")
+
+    def test_gpos_type_1_chained_exception1(self):
+        with self.assertRaisesRegex(FeatureLibError, "Positioning values are allowed"):
+            doc = self.parse("feature kern {"
+                             "    pos [A B]' [T Y]' comma a <0 0 0 0>;"
+                             "} kern;")
+
+    def test_gpos_type_1_chained_exception2(self):
+        with self.assertRaisesRegex(FeatureLibError, "Positioning values are allowed"):
+            doc = self.parse("feature kern {"
+                             "    pos [A B]' <0 0 0 0> [T Y]' comma a <0 0 0 0>;"
+                             "} kern;")
+
+    def test_gpos_type_1_chained_exception3(self):
+        with self.assertRaisesRegex(FeatureLibError, "Positioning cannot be applied"):
+            doc = self.parse("feature kern {"
+                             "    pos [A B] <0 0 0 0> [T Y]' comma a <0 0 0 0>;"
+                             "} kern;")
+
+    def test_gpos_type_1_chained_exception4(self):
+        with self.assertRaisesRegex(FeatureLibError, "Positioning values are allowed"):
+            doc = self.parse("feature kern {"
+                             "    pos a' b c 123 d;"
+                             "} kern;")
+
     def test_gpos_type_2_format_a(self):
         doc = self.parse("feature kern {"
                          "    pos [T V] -60 [a b c] <1 2 3 4>;"