diff --git a/Lib/fontTools/feaLib/ast.py b/Lib/fontTools/feaLib/ast.py index e52d768ec..bbe6e6e74 100644 --- a/Lib/fontTools/feaLib/ast.py +++ b/Lib/fontTools/feaLib/ast.py @@ -1256,23 +1256,29 @@ class MultipleSubstStatement(Statement): """Calls the builder object's ``add_multiple_subst`` callback.""" prefix = [p.glyphSet() for p in self.prefix] suffix = [s.glyphSet() for s in self.suffix] - if not self.replacement and hasattr(self.glyph, "glyphSet"): - for glyph in self.glyph.glyphSet(): - builder.add_multiple_subst( - self.location, - prefix, - glyph, - suffix, - self.replacement, - self.forceChain, - ) + if hasattr(self.glyph, "glyphSet"): + originals = self.glyph.glyphSet() else: + originals = [self.glyph] + count = len(originals) + replaces = [] + for r in self.replacement: + if hasattr(r, "glyphSet"): + replace = r.glyphSet() + else: + replace = [r] + if len(replace) == 1 and len(replace) != count: + replace = replace * count + replaces.append(replace) + replaces = list(zip(*replaces)) + + for i, original in enumerate(originals): builder.add_multiple_subst( self.location, prefix, - self.glyph, + original, suffix, - self.replacement, + replaces and replaces[i] or (), self.forceChain, ) diff --git a/Lib/fontTools/feaLib/parser.py b/Lib/fontTools/feaLib/parser.py index b325b3649..c8f0acca3 100644 --- a/Lib/fontTools/feaLib/parser.py +++ b/Lib/fontTools/feaLib/parser.py @@ -925,22 +925,27 @@ class Parser(object): # GSUB lookup type 2: Multiple substitution. # Format: "substitute f_f_i by f f i;" - if ( - not reverse - and len(old) == 1 - and len(old[0].glyphSet()) == 1 - and len(new) > 1 - and max([len(n.glyphSet()) for n in new]) == 1 - and num_lookups == 0 - ): + # + # GlyphsApp introduces two additional formats: + # Format 1: "substitute [f_i f_l] by [f f] [i l];" + # Format 2: "substitute [f_i f_l] by f [i l];" + # http://handbook.glyphsapp.com/en/layout/multiple-substitution-with-classes/ + if not reverse and len(old) == 1 and len(new) > 1 and num_lookups == 0: + count = len(old[0].glyphSet()) for n in new: if not list(n.glyphSet()): raise FeatureLibError("Empty class in replacement", location) + if not isinstance(n, self.ast.GlyphName) and len(n.glyphSet()) != count: + raise FeatureLibError( + f'Expected a glyph class with {count} elements after "by", ' + f"but found a glyph class with {len(n.glyphSet())} elements", + location, + ) return self.ast.MultipleSubstStatement( old_prefix, - tuple(old[0].glyphSet())[0], + old[0], old_suffix, - tuple([list(n.glyphSet())[0] for n in new]), + new, forceChain=hasMarks, location=location, ) diff --git a/Tests/feaLib/data/GSUB_2.fea b/Tests/feaLib/data/GSUB_2.fea index d2a3cb101..078cbec39 100644 --- a/Tests/feaLib/data/GSUB_2.fea +++ b/Tests/feaLib/data/GSUB_2.fea @@ -12,3 +12,20 @@ feature f2 { sub f_i by f i; sub f_f_i by f f i; } f2; + +feature f3 { + sub [f_i f_l f_f_i f_f_l] by f [i l f_i f_l]; +} f3; + +feature f4 { + sub [f_i f_l f_f_i f_f_l] by [f f f_f f_f] [i l i l]; +} f4; + +@class = [f_i f_l]; +lookup l1 { + sub @class by f [i l]; +} l1; + +feature f5 { + sub @class' lookup l1 [i l]; +} f5; diff --git a/Tests/feaLib/data/GSUB_2.ttx b/Tests/feaLib/data/GSUB_2.ttx index b91c20fe0..b2bd21bd7 100644 --- a/Tests/feaLib/data/GSUB_2.ttx +++ b/Tests/feaLib/data/GSUB_2.ttx @@ -10,16 +10,19 @@ - + @@ -34,9 +37,30 @@ + + + + + + + + + + + + + + + + + + + + + - + @@ -57,6 +81,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/feaLib/parser_test.py b/Tests/feaLib/parser_test.py index 6bf9c6a87..54c7c4426 100644 --- a/Tests/feaLib/parser_test.py +++ b/Tests/feaLib/parser_test.py @@ -44,7 +44,7 @@ GLYPHNAMES = ( a.swash b.swash x.swash y.swash z.swash foobar foo.09 foo.1234 foo.9876 one two five six acute grave dieresis umlaut cedilla ogonek macron - a_f_f_i o_f_f_i f_i f_f_i one.fitted one.oldstyle a.1 a.2 a.3 c_t + a_f_f_i o_f_f_i f_i f_l f_f_i one.fitted one.oldstyle a.1 a.2 a.3 c_t PRE SUF FIX BACK TRACK LOOK AHEAD ampersand ampersand.1 ampersand.2 cid00001 cid00002 cid00003 cid00004 cid00005 cid00006 cid00007 cid12345 cid78987 cid00999 cid01000 cid01001 cid00998 cid00995 @@ -1610,24 +1610,47 @@ class ParserTest(unittest.TestCase): doc = self.parse("lookup Look {substitute f_f_i by f f i;} Look;") sub = doc.statements[0].statements[0] self.assertIsInstance(sub, ast.MultipleSubstStatement) - self.assertEqual(sub.glyph, "f_f_i") - self.assertEqual(sub.replacement, ("f", "f", "i")) + self.assertEqual(glyphstr([sub.glyph]), "f_f_i") + self.assertEqual(glyphstr(sub.replacement), "f f i") def test_substitute_multiple_chained(self): # chain to GSUB LookupType 2 doc = self.parse("lookup L {sub [A-C] f_f_i' [X-Z] by f f i;} L;") sub = doc.statements[0].statements[0] self.assertIsInstance(sub, ast.MultipleSubstStatement) - self.assertEqual(sub.glyph, "f_f_i") - self.assertEqual(sub.replacement, ("f", "f", "i")) + self.assertEqual(glyphstr([sub.glyph]), "f_f_i") + self.assertEqual(glyphstr(sub.replacement), "f f i") def test_substitute_multiple_force_chained(self): doc = self.parse("lookup L {sub f_f_i' by f f i;} L;") sub = doc.statements[0].statements[0] self.assertIsInstance(sub, ast.MultipleSubstStatement) - self.assertEqual(sub.glyph, "f_f_i") - self.assertEqual(sub.replacement, ("f", "f", "i")) + self.assertEqual(glyphstr([sub.glyph]), "f_f_i") + self.assertEqual(glyphstr(sub.replacement), "f f i") self.assertEqual(sub.asFea(), "sub f_f_i' by f f i;") + def test_substitute_multiple_classes(self): + doc = self.parse("lookup Look {substitute [f_i f_l] by [f f] [i l];} Look;") + sub = doc.statements[0].statements[0] + self.assertIsInstance(sub, ast.MultipleSubstStatement) + self.assertEqual(glyphstr([sub.glyph]), "[f_i f_l]") + self.assertEqual(glyphstr(sub.replacement), "[f f] [i l]") + + def test_substitute_multiple_classes_mixed(self): + doc = self.parse("lookup Look {substitute [f_i f_l] by f [i l];} Look;") + sub = doc.statements[0].statements[0] + self.assertIsInstance(sub, ast.MultipleSubstStatement) + self.assertEqual(glyphstr([sub.glyph]), "[f_i f_l]") + self.assertEqual(glyphstr(sub.replacement), "f [i l]") + + def test_substitute_multiple_classes_mismatch(self): + self.assertRaisesRegex( + FeatureLibError, + 'Expected a glyph class with 2 elements after "by", ' + "but found a glyph class with 1 elements", + self.parse, + "lookup Look {substitute [f_i f_l] by [f] [i l];} Look;", + ) + def test_substitute_multiple_by_mutliple(self): self.assertRaisesRegex( FeatureLibError,