diff --git a/Lib/fontTools/feaLib/ast.py b/Lib/fontTools/feaLib/ast.py index 40f20256e..7ff5c53f4 100644 --- a/Lib/fontTools/feaLib/ast.py +++ b/Lib/fontTools/feaLib/ast.py @@ -138,6 +138,17 @@ class MultipleSubstitution(Statement): self.glyph, self.replacement) +class ReverseChainingSingleSubstitution(Statement): + def __init__(self, location, old_prefix, old_suffix, mapping): + Statement.__init__(self, location) + self.old_prefix, self.old_suffix = old_prefix, old_suffix + self.mapping = mapping + + def build(self, builder): + builder.add_reverse_chaining_single_substitution( + self.location, self.old_prefix, self.old_suffix, self.mapping) + + class SingleSubstitution(Statement): def __init__(self, location, mapping): Statement.__init__(self, location) @@ -171,8 +182,7 @@ class SubstitutionRule(Statement): def build(self, builder): builder.add_substitution( - self.location, - self.old_prefix, self.old, self.old_suffix, + self.location, self.old_prefix, self.old, self.old_suffix, self.new, self.lookups) diff --git a/Lib/fontTools/feaLib/builder.py b/Lib/fontTools/feaLib/builder.py index ce4e04e77..73e33560d 100644 --- a/Lib/fontTools/feaLib/builder.py +++ b/Lib/fontTools/feaLib/builder.py @@ -247,18 +247,26 @@ class Builder(object): self.set_language(location, "dflt", include_default=True, required=False) - def add_substitution(self, location, old_prefix, old, old_suffix, new, - lookups): - assert len(new) == 0, new + def find_lookup_builders_(self, lookups): + """Helper for building chain contextual substitutions + + Given a list of lookup names, finds the LookupBuilder for each name. + If an input name is None, it gets mapped to a None LookupBuilder. + """ lookup_builders = [] for lookup in lookups: if lookup is not None: lookup_builders.append(self.named_lookups_.get(lookup.name)) else: lookup_builders.append(None) + return lookup_builders + + def add_substitution(self, location, old_prefix, old, old_suffix, new, + lookups): + assert len(new) == 0, new lookup = self.get_lookup_(location, ChainContextSubstBuilder) lookup.substitutions.append((old_prefix, old, old_suffix, - lookup_builders)) + self.find_lookup_builders_(lookups))) def add_alternate_substitution(self, location, glyph, from_class): lookup = self.get_lookup_(location, AlternateSubstBuilder) @@ -280,6 +288,11 @@ class Builder(object): location) lookup.mapping[glyph] = replacements + def add_reverse_chaining_single_substitution(self, location, old_prefix, + old_suffix, mapping): + lookup = self.get_lookup_(location, ReverseChainSingleSubstBuilder) + lookup.substitutions.append((old_prefix, old_suffix, mapping)) + def add_single_substitution(self, location, mapping): lookup = self.get_lookup_(location, SingleSubstBuilder) for (from_glyph, to_glyph) in mapping.items(): @@ -304,6 +317,33 @@ class LookupBuilder(object): self.table == other.table and self.lookup_flag == other.lookup_flag) + @staticmethod + def setBacktrackCoverage_(prefix, subtable): + subtable.BacktrackGlyphCount = len(prefix) + subtable.BacktrackCoverage = [] + for p in reversed(prefix): + coverage = otTables.BacktrackCoverage() + coverage.glyphs = sorted(list(p)) + subtable.BacktrackCoverage.append(coverage) + + @staticmethod + def setLookAheadCoverage_(suffix, subtable): + subtable.LookAheadGlyphCount = len(suffix) + subtable.LookAheadCoverage = [] + for s in suffix: + coverage = otTables.LookAheadCoverage() + coverage.glyphs = sorted(list(s)) + subtable.LookAheadCoverage.append(coverage) + + @staticmethod + def setInputCoverage_(glyphs, subtable): + subtable.InputGlyphCount = len(glyphs) + subtable.InputCoverage = [] + for g in glyphs: + coverage = otTables.InputCoverage() + coverage.glyphs = sorted(list(g)) + subtable.InputCoverage.append(coverage) + class AlternateSubstBuilder(LookupBuilder): def __init__(self, location, lookup_flag): @@ -343,27 +383,9 @@ class ChainContextSubstBuilder(LookupBuilder): st = otTables.ChainContextSubst() lookup.SubTable.append(st) st.Format = 3 - - st.BacktrackGlyphCount = len(prefix) - st.BacktrackCoverage = [] - for p in reversed(prefix): - coverage = otTables.BacktrackCoverage() - coverage.glyphs = sorted(list(p)) - st.BacktrackCoverage.append(coverage) - - st.InputGlyphCount = len(input) - st.InputCoverage = [] - for i in input: - coverage = otTables.InputCoverage() - coverage.glyphs = sorted(list(i)) - st.InputCoverage.append(coverage) - - st.LookAheadGlyphCount = len(suffix) - st.LookAheadCoverage = [] - for s in suffix: - coverage = otTables.LookAheadCoverage() - coverage.glyphs = sorted(list(s)) - st.LookAheadCoverage.append(coverage) + self.setBacktrackCoverage_(prefix, st) + self.setLookAheadCoverage_(suffix, st) + self.setInputCoverage_(input, st) st.SubstCount = len([l for l in lookups if l is not None]) st.SubstLookupRecord = [] @@ -442,6 +464,36 @@ class MultipleSubstBuilder(LookupBuilder): return lookup +class ReverseChainSingleSubstBuilder(LookupBuilder): + def __init__(self, location, lookup_flag): + LookupBuilder.__init__(self, location, 'GSUB', 8, lookup_flag) + self.substitutions = [] # (prefix, suffix, mapping) + + def equals(self, other): + return (LookupBuilder.equals(self, other) and + self.substitutions == other.substitutions) + + def build(self): + lookup = otTables.Lookup() + lookup.SubTable = [] + for prefix, suffix, mapping in self.substitutions: + st = otTables.ReverseChainSingleSubst() + st.Format = 1 + lookup.SubTable.append(st) + self.setBacktrackCoverage_(prefix, st) + self.setLookAheadCoverage_(suffix, st) + coverage = sorted(list(mapping.keys())) + st.Coverage = otTables.Coverage() + st.Coverage.glyphs = coverage + st.GlyphCount = len(coverage) + st.Substitute = [mapping[g] for g in coverage] + + lookup.LookupFlag = self.lookup_flag + lookup.LookupType = self.lookup_type + lookup.SubTableCount = len(lookup.SubTable) + return lookup + + class SingleSubstBuilder(LookupBuilder): def __init__(self, location, lookup_flag): LookupBuilder.__init__(self, location, 'GSUB', 1, lookup_flag) diff --git a/Lib/fontTools/feaLib/builder_test.py b/Lib/fontTools/feaLib/builder_test.py index f4ff12c07..adf85e309 100644 --- a/Lib/fontTools/feaLib/builder_test.py +++ b/Lib/fontTools/feaLib/builder_test.py @@ -104,6 +104,11 @@ class BuilderTest(unittest.TestCase): " sub f_f_i by f f i;" "} test;") + def test_reverseChainingSingleSubst(self): + font = TTFont() + addOpenTypeFeatures(self.getpath("GSUB_8.fea"), font) + self.expect_ttx(font, self.getpath("GSUB_8.ttx")) + def test_singleSubst_multipleSubstitutionsForSameGlyph(self): self.assertRaisesRegex( FeatureLibError, @@ -138,6 +143,12 @@ class BuilderTest(unittest.TestCase): addOpenTypeFeatures(self.getpath("spec5fi1.fea"), font) self.expect_ttx(font, self.getpath("spec5fi1.ttx")) + def test_spec5h1(self): + # OpenType Feature File specification, section 5.h, example 1. + font = TTFont() + addOpenTypeFeatures(self.getpath("spec5h1.fea"), font) + self.expect_ttx(font, self.getpath("spec5h1.ttx")) + def test_languagesystem(self): builder = Builder(None, TTFont()) builder.add_language_system(None, 'latn', 'FRA') diff --git a/Lib/fontTools/feaLib/parser.py b/Lib/fontTools/feaLib/parser.py index 727d2ebde..9c2be7ff6 100644 --- a/Lib/fontTools/feaLib/parser.py +++ b/Lib/fontTools/feaLib/parser.py @@ -207,8 +207,9 @@ class Parser(object): return ast.ScriptStatement(location, script) def parse_substitute_(self): - assert self.cur_token_ in {"substitute", "sub"} + assert self.cur_token_ in {"substitute", "sub", "reversesub", "rsub"} location = self.cur_token_location_ + reverse = self.cur_token_ in {"reversesub", "rsub"} old_prefix, old, lookups, old_suffix = self.parse_glyph_pattern_() new = [] @@ -230,6 +231,10 @@ class Parser(object): # GSUB lookup type 3: Alternate substitution. # Format: "substitute a from [a.1 a.2 a.3];" if keyword == "from": + if reverse: + raise FeatureLibError( + 'Reverse chaining substitutions do not support "from"', + location) if len(old) != 1 or len(old[0]) != 1: raise FeatureLibError( 'Expected a single glyph before "from"', @@ -246,7 +251,7 @@ class Parser(object): # Format A: "substitute a by a.sc;" # Format B: "substitute [one.fitted one.oldstyle] by one;" # Format C: "substitute [a-d] by [A.sc-D.sc];" - if (len(old_prefix) == 0 and len(old_suffix) == 0 and + if (not reverse and len(old_prefix) == 0 and len(old_suffix) == 0 and len(old) == 1 and len(new) == 1 and num_lookups == 0): glyphs, replacements = sorted(list(old[0])), sorted(list(new[0])) if len(replacements) == 1: @@ -261,7 +266,7 @@ class Parser(object): # GSUB lookup type 2: Multiple substitution. # Format: "substitute f_f_i by f f i;" - if (len(old_prefix) == 0 and len(old_suffix) == 0 and + if (not reverse and len(old_prefix) == 0 and len(old_suffix) == 0 and len(old) == 1 and len(old[0]) == 1 and len(new) > 1 and max([len(n) for n in new]) == 1 and num_lookups == 0): @@ -270,14 +275,43 @@ class Parser(object): # GSUB lookup type 4: Ligature substitution. # Format: "substitute f f i by f_f_i;" - if (len(old_prefix) == 0 and len(old_suffix) == 0 and + if (not reverse and len(old_prefix) == 0 and len(old_suffix) == 0 and len(old) > 1 and len(new) == 1 and len(new[0]) == 1 and num_lookups == 0): return ast.LigatureSubstitution(location, old, list(new[0])[0]) + # GSUB lookup type 8: Reverse chaining substitution. + if reverse: + if len(old) != 1: + raise FeatureLibError( + "In reverse chaining single substitutions, " + "only a single glyph or glyph class can be replaced", + location) + if len(new) != 1: + raise FeatureLibError( + 'In reverse chaining single substitutions, ' + 'the replacement (after "by") must be a single glyph ' + 'or glyph class', location) + if num_lookups != 0: + raise FeatureLibError( + "Reverse chaining substitutions cannot call named lookups", + location) + glyphs, replacements = sorted(list(old[0])), sorted(list(new[0])) + if len(replacements) == 1: + replacements = replacements * len(glyphs) + if len(glyphs) != len(replacements): + raise FeatureLibError( + 'Expected a glyph class with %d elements after "by", ' + 'but found a glyph class with %d elements' % + (len(glyphs), len(replacements)), location) + return ast.ReverseChainingSingleSubstitution( + location, old_prefix, old_suffix, + dict(zip(glyphs, replacements))) + rule = ast.SubstitutionRule(location, old, new) rule.old_prefix, rule.old_suffix = old_prefix, old_suffix rule.lookups = lookups + rule.reverse = reverse return rule def parse_subtable_(self): @@ -370,8 +404,8 @@ class Parser(object): statements.append(self.parse_lookup_(vertical)) elif self.is_cur_keyword_("script"): statements.append(self.parse_script_()) - elif (self.is_cur_keyword_("substitute") or - self.is_cur_keyword_("sub")): + elif (self.is_cur_keyword_({"sub", "substitute", + "rsub", "reversesub"})): statements.append(self.parse_substitute_()) elif self.is_cur_keyword_("subtable"): statements.append(self.parse_subtable_()) @@ -393,7 +427,12 @@ class Parser(object): self.expect_symbol_(";") def is_cur_keyword_(self, k): - return (self.cur_token_type_ is Lexer.NAME) and (self.cur_token_ == k) + if self.cur_token_type_ is Lexer.NAME: + if isinstance(k, type("")): # basestring is gone in Python3 + return self.cur_token_ == k + else: + return self.cur_token_ in k + return False def expect_tag_(self): self.advance_lexer_() diff --git a/Lib/fontTools/feaLib/parser_test.py b/Lib/fontTools/feaLib/parser_test.py index b9dfbc0d8..05dc7d87f 100644 --- a/Lib/fontTools/feaLib/parser_test.py +++ b/Lib/fontTools/feaLib/parser_test.py @@ -256,6 +256,64 @@ class ParserTest(unittest.TestCase): FeatureLibError, 'Unknown lookup "Huh"', self.parse, "feature liga {lookup Huh;} liga;") + def test_rsub_format_a(self): + doc = self.parse("feature test {rsub a [b B] c' d [e E] by C;} test;") + rsub = doc.statements[0].statements[0] + self.assertEqual(type(rsub), ast.ReverseChainingSingleSubstitution) + self.assertEqual(rsub.old_prefix, [{"a"}, {"b", "B"}]) + self.assertEqual(rsub.mapping, {"c": "C"}) + self.assertEqual(rsub.old_suffix, [{"d"}, {"e", "E"}]) + + def test_rsub_format_b(self): + doc = self.parse( + "feature smcp {" + " reversesub A B [one.fitted one.oldstyle]' C [d D] by one;" + "} smcp;") + rsub = doc.statements[0].statements[0] + self.assertEqual(type(rsub), ast.ReverseChainingSingleSubstitution) + self.assertEqual(rsub.old_prefix, [{"A"}, {"B"}]) + self.assertEqual(rsub.old_suffix, [{"C"}, {"d", "D"}]) + self.assertEqual(rsub.mapping, { + "one.fitted": "one", + "one.oldstyle": "one" + }) + + def test_rsub_format_c(self): + doc = self.parse( + "feature test {" + " reversesub BACK TRACK [a-d]' LOOK AHEAD by [A.sc-D.sc];" + "} test;") + rsub = doc.statements[0].statements[0] + self.assertEqual(type(rsub), ast.ReverseChainingSingleSubstitution) + self.assertEqual(rsub.old_prefix, [{"BACK"}, {"TRACK"}]) + self.assertEqual(rsub.old_suffix, [{"LOOK"}, {"AHEAD"}]) + self.assertEqual(rsub.mapping, { + "a": "A.sc", + "b": "B.sc", + "c": "C.sc", + "d": "D.sc" + }) + + def test_rsub_from(self): + self.assertRaisesRegex( + FeatureLibError, + 'Reverse chaining substitutions do not support "from"', + self.parse, "feature test {rsub a from [a.1 a.2 a.3];} test;") + + def test_rsub_nonsingle(self): + self.assertRaisesRegex( + FeatureLibError, + "In reverse chaining single substitutions, only a single glyph " + "or glyph class can be replaced", + self.parse, "feature test {rsub c d by c_d;} test;") + + def test_rsub_multiple_replacement_glyphs(self): + self.assertRaisesRegex( + FeatureLibError, + 'In reverse chaining single substitutions, the replacement ' + '\(after "by"\) must be a single glyph or glyph class', + self.parse, "feature test {rsub f_i by f i;} test;") + def test_script(self): doc = self.parse("feature test {script cyrl;} test;") s = doc.statements[0].statements[0] diff --git a/Lib/fontTools/feaLib/testdata/GSUB_8.fea b/Lib/fontTools/feaLib/testdata/GSUB_8.fea new file mode 100644 index 000000000..a56e09302 --- /dev/null +++ b/Lib/fontTools/feaLib/testdata/GSUB_8.fea @@ -0,0 +1,11 @@ +languagesystem DFLT dflt; + +feature test { + reversesub [a A] [b B] [c C] q' [d D] [e E] [f F] by Q; + reversesub [a A] [b B] [c C] [s-z]' [d D] [e E] [f F] by [S-Z]; + + # Having no context for a reverse chaining substitution rule + # is a little degenerate (we define a chain without linking it + # to anything else), but makeotf accepts this. + reversesub p by P; +} test; diff --git a/Lib/fontTools/feaLib/testdata/GSUB_8.ttx b/Lib/fontTools/feaLib/testdata/GSUB_8.ttx new file mode 100644 index 000000000..b438a5148 --- /dev/null +++ b/Lib/fontTools/feaLib/testdata/GSUB_8.ttx @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Lib/fontTools/feaLib/testdata/spec5h1.fea b/Lib/fontTools/feaLib/testdata/spec5h1.fea new file mode 100644 index 000000000..6a28cbebe --- /dev/null +++ b/Lib/fontTools/feaLib/testdata/spec5h1.fea @@ -0,0 +1,8 @@ +# OpenType Feature File specification, section 5.h, example 1. +# http://www.adobe.com/devnet/opentype/afdko/topic_feature_file_syntax.html + +languagesystem DFLT dflt; + +feature test { + reversesub [a e n] d' by d.alt; +} test; diff --git a/Lib/fontTools/feaLib/testdata/spec5h1.ttx b/Lib/fontTools/feaLib/testdata/spec5h1.ttx new file mode 100644 index 000000000..13febe1a6 --- /dev/null +++ b/Lib/fontTools/feaLib/testdata/spec5h1.ttx @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +