Repository: fonttools/fonttools

Issue:
[feaLib] allow string as input
feaLib should have an entry-point that takes a string of features as input and works with that.

See jamesgk/ufo2ft#9. cc @brawer



Discussion:
@brawer, any plans to support this?

Sure, of course! Mind sending a pull request?


Resolving patch:
diff --git a/Lib/fontTools/feaLib/builder.py b/Lib/fontTools/feaLib/builder.py
--- a/Lib/fontTools/feaLib/builder.py
+++ b/Lib/fontTools/feaLib/builder.py
@@ -8,15 +8,19 @@
 import itertools
 
 
-def addOpenTypeFeatures(featurefile_path, font):
-    builder = Builder(featurefile_path, font)
+def addOpenTypeFeatures(font, featurefile_path=None, featurefile_data=None):
+    # we may have a path but no data, or data but no path
+    assert featurefile_path or featurefile_data
+    # pack this until we yield it the lexer
+    featurefile_path_data = (featurefile_path, featurefile_data)
+    builder = Builder(font, featurefile_path_data)
     builder.build()
 
 
 class Builder(object):
-    def __init__(self, featurefile_path, font):
-        self.featurefile_path = featurefile_path
+    def __init__(self, font, featurefile_path_data):
         self.font = font
+        self.featurefile_path_data = featurefile_path_data
         self.glyphMap = font.getReverseGlyphMap()
         self.default_language_systems_ = set()
         self.script_ = None
@@ -49,7 +53,7 @@ def __init__(self, featurefile_path, font):
         self.markFilterSets_ = {}  # frozenset({"acute", "grave"}) --> 4
 
     def build(self):
-        self.parseTree = Parser(self.featurefile_path).parse()
+        self.parseTree = Parser(self.featurefile_path_data).parse()
         self.parseTree.build(self)
         self.build_feature_aalt_()
         self.build_head()
diff --git a/Lib/fontTools/feaLib/lexer.py b/Lib/fontTools/feaLib/lexer.py
--- a/Lib/fontTools/feaLib/lexer.py
+++ b/Lib/fontTools/feaLib/lexer.py
@@ -166,8 +166,8 @@ def scan_until_(self, stop_at):
 
 
 class IncludingLexer(object):
-    def __init__(self, filename):
-        self.lexers_ = [self.make_lexer_(filename, (filename, 0, 0))]
+    def __init__(self, featurefile):
+        self.lexers_ = [self.make_lexer_(featurefile, (featurefile[0], 0, 0))]
 
     def __iter__(self):
         return self
@@ -195,16 +195,19 @@ def __next__(self):  # Python 3
                 if len(self.lexers_) >= 5:
                     raise FeatureLibError("Too many recursive includes",
                                           fname_location)
-                self.lexers_.append(self.make_lexer_(path, fname_location))
+                self.lexers_.append(self.make_lexer_((path, None), fname_location))
                 continue
             else:
                 return (token_type, token, location)
         raise StopIteration()
 
     @staticmethod
-    def make_lexer_(filename, location):
-        try:
-            with open(filename, "r", encoding="utf-8") as f:
-                return Lexer(f.read(), filename)
-        except IOError as err:
-            raise FeatureLibError(str(err), location)
+    def make_lexer_(featurefile_path_data, location):
+        filename, data = featurefile_path_data
+        if data is None:
+            try:
+                with open(filename, "r", encoding="utf-8") as f:
+                    data = f.read()
+            except IOError as err:
+                raise FeatureLibError(str(err), location)
+        return Lexer(data, filename)
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
@@ -9,7 +9,7 @@
 
 
 class Parser(object):
-    def __init__(self, path):
+    def __init__(self, featurefile):
         self.doc_ = ast.FeatureFile()
         self.anchors_ = SymbolTable()
         self.glyphclasses_ = SymbolTable()
@@ -20,7 +20,7 @@ def __init__(self, path):
         }
         self.next_token_type_, self.next_token_ = (None, None)
         self.next_token_location_ = None
-        self.lexer_ = IncludingLexer(path)
+        self.lexer_ = IncludingLexer(featurefile)
         self.advance_lexer_()
 
     def parse(self):


Test patch:
diff --git a/Lib/fontTools/feaLib/builder_test.py b/Lib/fontTools/feaLib/builder_test.py
--- a/Lib/fontTools/feaLib/builder_test.py
+++ b/Lib/fontTools/feaLib/builder_test.py
@@ -117,12 +117,12 @@ def build(self, featureFile):
         with open(path, "w", encoding="utf-8") as outfile:
             outfile.write(featureFile)
         font = makeTTFont()
-        addOpenTypeFeatures(path, font)
+        addOpenTypeFeatures(font, path)
         return font
 
     def check_feature_file(self, name):
         font = makeTTFont()
-        addOpenTypeFeatures(self.getpath("%s.fea" % name), font)
+        addOpenTypeFeatures(font, self.getpath("%s.fea" % name))
         self.expect_ttx(font, self.getpath("%s.ttx" % name))
         # Make sure we can produce binary OpenType tables, not just XML.
         for tag in ('GDEF', 'GSUB', 'GPOS'):
@@ -199,7 +199,7 @@ def test_GlyphClassDef_conflictingClasses(self):
             "} GDEF;")
 
     def test_languagesystem(self):
-        builder = Builder(None, makeTTFont())
+        builder = Builder(makeTTFont(), (None, None))
         builder.add_language_system(None, 'latn', 'FRA')
         builder.add_language_system(None, 'cyrl', 'RUS')
         builder.start_feature(location=None, name='test')
@@ -213,7 +213,7 @@ def test_languagesystem_duplicate(self):
             self.build, "languagesystem cyrl RUS; languagesystem cyrl RUS;")
 
     def test_languagesystem_none_specified(self):
-        builder = Builder(None, makeTTFont())
+        builder = Builder(makeTTFont(), (None, None))
         builder.start_feature(location=None, name='test')
         self.assertEqual(builder.language_systems, {('DFLT', 'dflt')})
 
@@ -225,7 +225,7 @@ def test_languagesystem_DFLT_dflt_not_first(self):
             self.build, "languagesystem latn TRK; languagesystem DFLT dflt;")
 
     def test_script(self):
-        builder = Builder(None, makeTTFont())
+        builder = Builder(makeTTFont(), (None, None))
         builder.start_feature(location=None, name='test')
         builder.set_script(location=None, script='cyrl')
         self.assertEqual(builder.language_systems, {('cyrl', 'dflt')})
@@ -243,7 +243,7 @@ def test_script_in_size_feature(self):
             self.build, "feature size { script latn; } size;")
 
     def test_language(self):
-        builder = Builder(None, makeTTFont())
+        builder = Builder(makeTTFont(), (None, None))
         builder.add_language_system(None, 'latn', 'FRA ')
         builder.start_feature(location=None, name='test')
         builder.set_script(location=None, script='cyrl')
diff --git a/Lib/fontTools/feaLib/lexer_test.py b/Lib/fontTools/feaLib/lexer_test.py
--- a/Lib/fontTools/feaLib/lexer_test.py
+++ b/Lib/fontTools/feaLib/lexer_test.py
@@ -131,7 +131,7 @@ def getpath(filename):
         return os.path.join(path, "testdata", filename)
 
     def test_include(self):
-        lexer = IncludingLexer(self.getpath("include4.fea"))
+        lexer = IncludingLexer((self.getpath("include4.fea"), None))
         result = ['%s %s:%d' % (token, os.path.split(loc[0])[1], loc[1])
                   for _, token, loc in lexer]
         self.assertEqual(result, [
@@ -147,15 +147,15 @@ def test_include(self):
         ])
 
     def test_include_limit(self):
-        lexer = IncludingLexer(self.getpath("include6.fea"))
+        lexer = IncludingLexer((self.getpath("include6.fea"), None))
         self.assertRaises(FeatureLibError, lambda: list(lexer))
 
     def test_include_self(self):
-        lexer = IncludingLexer(self.getpath("includeself.fea"))
+        lexer = IncludingLexer((self.getpath("includeself.fea"), None))
         self.assertRaises(FeatureLibError, lambda: list(lexer))
 
     def test_include_missing_file(self):
-        lexer = IncludingLexer(self.getpath("includemissingfile.fea"))
+        lexer = IncludingLexer((self.getpath("includemissingfile.fea"), None))
         self.assertRaises(FeatureLibError, lambda: list(lexer))
 
 
diff --git a/Lib/fontTools/feaLib/parser_test.py b/Lib/fontTools/feaLib/parser_test.py
--- a/Lib/fontTools/feaLib/parser_test.py
+++ b/Lib/fontTools/feaLib/parser_test.py
@@ -1084,7 +1084,7 @@ def test_substitute_ligature_chained(self):  # chain to GSUB LookupType 4
         self.assertEqual(glyphstr(sub.suffix), "Z")
 
     def test_substitute_lookups(self):  # GSUB LookupType 6
-        doc = Parser(self.getpath("spec5fi1.fea")).parse()
+        doc = Parser((self.getpath("spec5fi1.fea"), None)).parse()
         [langsys, ligs, sub, feature] = doc.statements
         self.assertEqual(feature.statements[0].lookups, [ligs, None, sub])
         self.assertEqual(feature.statements[1].lookups, [ligs, None, sub])
@@ -1257,7 +1257,7 @@ def parse(self, text):
         path = os.path.join(self.tempdir, "tmp%d.fea" % self.num_tempfiles)
         with open(path, "w", encoding="utf-8") as outfile:
             outfile.write(text)
-        return Parser(path).parse()
+        return Parser((path, None)).parse()
 
     @staticmethod
     def getpath(testfile):