From 6810cf5ec03aebbbd3f3478aad0fe5d0d31202ce Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 18 Mar 2021 17:23:30 +0000 Subject: [PATCH 01/22] Parse variable scalars in feature files. See https://github.com/adobe-type-tools/afdko/issues/153#issuecomment-801522800 --- Lib/fontTools/feaLib/parser.py | 48 ++++++++++++++-- Lib/fontTools/feaLib/variableScalar.py | 78 ++++++++++++++++++++++++++ Tests/feaLib/parser_test.py | 13 +++++ 3 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 Lib/fontTools/feaLib/variableScalar.py diff --git a/Lib/fontTools/feaLib/parser.py b/Lib/fontTools/feaLib/parser.py index 830a085774..bb51e91895 100644 --- a/Lib/fontTools/feaLib/parser.py +++ b/Lib/fontTools/feaLib/parser.py @@ -1,5 +1,6 @@ from fontTools.feaLib.error import FeatureLibError from fontTools.feaLib.lexer import Lexer, IncludingLexer, NonIncludingLexer +from fontTools.feaLib.variableScalar import VariableScalar from fontTools.misc.encodingTools import getEncoding from fontTools.misc.textTools import bytechr, tobytes, tostr import fontTools.feaLib.ast as ast @@ -152,7 +153,7 @@ def parse_anchor_(self): location=location, ) - x, y = self.expect_number_(), self.expect_number_() + x, y = self.expect_number_(variable=True), self.expect_number_(variable=True) contourpoint = None if self.next_token_ == "contourpoint": # Format B @@ -1616,10 +1617,10 @@ def parse_valuerecord_(self, vertical): xAdvance, yAdvance = (value.xAdvance, value.yAdvance) else: xPlacement, yPlacement, xAdvance, yAdvance = ( - self.expect_number_(), - self.expect_number_(), - self.expect_number_(), - self.expect_number_(), + self.expect_number_(variable=True), + self.expect_number_(variable=True), + self.expect_number_(variable=True), + self.expect_number_(variable=True), ) if self.next_token_ == "<": @@ -2080,12 +2081,47 @@ def expect_name_(self): return self.cur_token_ raise FeatureLibError("Expected a name", self.cur_token_location_) - def expect_number_(self): + def expect_number_(self, variable=False): self.advance_lexer_() if self.cur_token_type_ is Lexer.NUMBER: return self.cur_token_ + if variable and self.cur_token_type_ is Lexer.SYMBOL and self.cur_token_ == "(": + return self.expect_variable_scalar_() raise FeatureLibError("Expected a number", self.cur_token_location_) + def expect_variable_scalar_(self): + self.advance_lexer_() # "(" + scalar = VariableScalar() + while True: + if self.cur_token_type_ == Lexer.SYMBOL and self.cur_token_ == ")": + break + location, value = self.expect_master_() + scalar.add_value(location, value) + return scalar + + def expect_master_(self): + location = {} + while True: + if self.cur_token_type_ is not Lexer.NAME: + raise FeatureLibError("Expected an axis name", self.cur_token_location_) + axis = self.cur_token_ + self.advance_lexer_() + if not (self.cur_token_type_ is Lexer.SYMBOL and self.cur_token_ == "="): + raise FeatureLibError("Expected an equals sign", self.cur_token_location_) + value = self.expect_number_() + location[axis] = value + if self.next_token_type_ is Lexer.NAME and self.next_token_[0] == ":": + # Lexer has just read the value as a glyph name. We'll correct it later + break + self.advance_lexer_() + if not(self.cur_token_type_ is Lexer.SYMBOL and self.cur_token_ == ","): + raise FeatureLibError("Expected an comma or an equals sign", self.cur_token_location_) + self.advance_lexer_() + self.advance_lexer_() + value = int(self.cur_token_[1:]) + self.advance_lexer_() + return location, value + def expect_any_number_(self): self.advance_lexer_() if self.cur_token_type_ in Lexer.NUMBERS: diff --git a/Lib/fontTools/feaLib/variableScalar.py b/Lib/fontTools/feaLib/variableScalar.py new file mode 100644 index 0000000000..217e41f655 --- /dev/null +++ b/Lib/fontTools/feaLib/variableScalar.py @@ -0,0 +1,78 @@ +from fontTools.varLib.models import VariationModel, normalizeValue + + +class Location(dict): + def __hash__(self): + return hash(frozenset(self)) + + +class VariableScalar: + """A scalar with different values at different points in the designspace.""" + + def __init__(self, location_value={}): + self.values = {} + self.axes = {} + for location, value in location_value.items(): + self.add_value(location, value) + + def __repr__(self): + items = [] + for location,value in self.values.items(): + loc = ",".join(["%s=%i" % (ax,loc) for ax,loc in location.items()]) + items.append("%s:%i" % (loc, value)) + return "("+(" ".join(items))+")" + + @property + def axes_dict(self): + if not self.axes: + raise ValueError(".axes must be defined on variable scalar before interpolating") + return {ax.tag: ax for ax in self.axes} + + def _normalized_location(self, location): + normalized_location = {} + for axtag in location.keys(): + if axtag not in self.axes_dict: + raise ValueError("Unknown axis %s in %s" % axtag, location) + axis = self.axes_dict[axtag] + normalized_location[axtag] = normalizeValue( + location[axtag], (axis.minimum, axis.default, axis.maximum) + ) + + for ax in self.axes: + if ax.tag not in normalized_location: + normalized_location[ax.tag] = 0 + + return Location(normalized_location) + + def add_value(self, location, value): + self.values[Location(location)] = value + + @property + def default(self): + key = {ax.tag: ax.default for ax in self.axes} + if key not in self.values: + raise ValueError("Default value could not be found") + # I *guess* we could interpolate one, but I don't know how. + return self.values[key] + + def value_at_location(self, location): + loc = location + if loc in self.values.keys(): + return self.values[loc] + values = list(self.values.values()) + return self.model.interpolateFromMasters(loc, values) + + @property + def model(self): + locations = [self._normalized_location(k) for k in self.values.keys()] + return VariationModel(locations) + + def get_deltas_and_supports(self): + values = list(self.values.values()) + return self.model.getDeltasAndSupports(values) + + def add_to_variation_store(self, store_builder): + deltas, supports = self.get_deltas_and_supports() + store_builder.setSupports(supports) + index = store_builder.storeDeltas(deltas) + return int(self.default), index diff --git a/Tests/feaLib/parser_test.py b/Tests/feaLib/parser_test.py index 11808190a3..3d11b878db 100644 --- a/Tests/feaLib/parser_test.py +++ b/Tests/feaLib/parser_test.py @@ -172,6 +172,14 @@ def test_anchor_format_e_undefined(self): " position cursive A ;" "} test;") + def test_anchor_variable_scalar(self): + doc = self.parse( + "feature test {" + " pos cursive A ;" + "} test;") + anchor = doc.statements[0].statements[0].entryAnchor + self.assertEqual(anchor.asFea(), "") + def test_anchordef(self): [foo] = self.parse("anchorDef 123 456 foo;").statements self.assertEqual(type(foo), ast.AnchorDefinition) @@ -1804,6 +1812,11 @@ def test_valuerecord_format_d(self): self.assertFalse(value) self.assertEqual(value.asFea(), "") + def test_valuerecord_variable_scalar(self): + doc = self.parse("feature test {valueRecordDef <0 (wght=200:-100 wght=900:-150 wght=900,wdth=150:-120) 0 0> foo;} test;") + value = doc.statements[0].statements[0].value + self.assertEqual(value.asFea(), "<0 (wght=200:-100 wght=900:-150 wght=900,wdth=150:-120) 0 0>") + def test_valuerecord_named(self): doc = self.parse("valueRecordDef <1 2 3 4> foo;" "feature liga {valueRecordDef bar;} liga;") From 3fd63587812cce4cb06210daafa8c214e199c276 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 18 Mar 2021 20:34:07 +0000 Subject: [PATCH 02/22] Build variable scalars for anchors/value records --- Lib/fontTools/feaLib/builder.py | 142 ++++++++++++++++++------- Lib/fontTools/feaLib/variableScalar.py | 24 +++-- Lib/fontTools/otlLib/builder.py | 15 ++- 3 files changed, 129 insertions(+), 52 deletions(-) diff --git a/Lib/fontTools/feaLib/builder.py b/Lib/fontTools/feaLib/builder.py index d6f41014f8..6d01a976ce 100644 --- a/Lib/fontTools/feaLib/builder.py +++ b/Lib/fontTools/feaLib/builder.py @@ -8,6 +8,7 @@ ) from fontTools.feaLib.parser import Parser from fontTools.feaLib.ast import FeatureFile +from fontTools.feaLib.variableScalar import VariableScalar from fontTools.otlLib import builder as otl from fontTools.otlLib.maxContextCalc import maxCtxFont from fontTools.ttLib import newTable, getTableModule @@ -30,6 +31,8 @@ ChainContextualRule, ) from fontTools.otlLib.error import OpenTypeLibError +from fontTools.varLib.varStore import OnlineVarStoreBuilder +from fontTools.varLib.builder import buildVarDevTable from collections import defaultdict import itertools from io import StringIO @@ -111,6 +114,12 @@ def __init__(self, font, featurefile): else: self.parseTree, self.file = None, featurefile self.glyphMap = font.getReverseGlyphMap() + self.varstorebuilder = None + if "fvar" in font: + self.axes = font["fvar"].axes + self.varstorebuilder = OnlineVarStoreBuilder( + [ax.axisTag for ax in self.axes] + ) self.default_language_systems_ = set() self.script_ = None self.lookupflag_ = 0 @@ -214,6 +223,8 @@ def build(self, tables=None, debug=False): self.font["GDEF"] = gdef elif "GDEF" in self.font: del self.font["GDEF"] + elif self.varstorebuilder: + raise FeatureLibError("Must save GDEF when compiling a variable font") if "BASE" in tables: base = self.buildBASE() if base: @@ -744,6 +755,16 @@ def buildGDEF(self): gdef.MarkAttachClassDef = self.buildGDEFMarkAttachClassDef_() gdef.MarkGlyphSetsDef = self.buildGDEFMarkGlyphSetsDef_() gdef.Version = 0x00010002 if gdef.MarkGlyphSetsDef else 0x00010000 + if self.varstorebuilder: + store = self.varstorebuilder.finish() + if store.VarData: + gdef.Version = 0x00010003 + gdef.VarStore = store + varidx_map = store.optimize() + + gdef.remap_device_varidxes(varidx_map) + if 'GPOS' in self.font: + self.font['GPOS'].table.remap_device_varidxes(varidx_map) if any( ( gdef.GlyphClassDef, @@ -752,7 +773,7 @@ def buildGDEF(self): gdef.MarkAttachClassDef, gdef.MarkGlyphSetsDef, ) - ): + ) or hasattr(gdef, "VarStore"): result = newTable("GDEF") result.table = gdef return result @@ -1298,8 +1319,8 @@ def add_cursive_pos(self, location, glyphclass, entryAnchor, exitAnchor): lookup.add_attachment( location, glyphclass, - makeOpenTypeAnchor(entryAnchor), - makeOpenTypeAnchor(exitAnchor), + self.makeOpenTypeAnchor(location, entryAnchor), + self.makeOpenTypeAnchor(location, exitAnchor), ) def add_marks_(self, location, lookupBuilder, marks): @@ -1308,7 +1329,7 @@ def add_marks_(self, location, lookupBuilder, marks): for markClassDef in markClass.definitions: for mark in markClassDef.glyphs.glyphSet(): if mark not in lookupBuilder.marks: - otMarkAnchor = makeOpenTypeAnchor(markClassDef.anchor) + otMarkAnchor = self.makeOpenTypeAnchor(location, markClassDef.anchor) lookupBuilder.marks[mark] = (markClass.name, otMarkAnchor) else: existingMarkClass = lookupBuilder.marks[mark][0] @@ -1323,7 +1344,7 @@ def add_mark_base_pos(self, location, bases, marks): builder = self.get_lookup_(location, MarkBasePosBuilder) self.add_marks_(location, builder, marks) for baseAnchor, markClass in marks: - otBaseAnchor = makeOpenTypeAnchor(baseAnchor) + otBaseAnchor = self.makeOpenTypeAnchor(location, baseAnchor) for base in bases: builder.bases.setdefault(base, {})[markClass.name] = otBaseAnchor @@ -1334,7 +1355,7 @@ def add_mark_lig_pos(self, location, ligatures, components): anchors = {} self.add_marks_(location, builder, marks) for ligAnchor, markClass in marks: - anchors[markClass.name] = makeOpenTypeAnchor(ligAnchor) + anchors[markClass.name] = self.makeOpenTypeAnchor(location, ligAnchor) componentAnchors.append(anchors) for glyph in ligatures: builder.ligatures[glyph] = componentAnchors @@ -1343,7 +1364,7 @@ def add_mark_mark_pos(self, location, baseMarks, marks): builder = self.get_lookup_(location, MarkMarkPosBuilder) self.add_marks_(location, builder, marks) for baseAnchor, markClass in marks: - otBaseAnchor = makeOpenTypeAnchor(baseAnchor) + otBaseAnchor = self.makeOpenTypeAnchor(location, baseAnchor) for baseMark in baseMarks: builder.baseMarks.setdefault(baseMark, {})[ markClass.name @@ -1351,8 +1372,8 @@ def add_mark_mark_pos(self, location, baseMarks, marks): def add_class_pair_pos(self, location, glyphclass1, value1, glyphclass2, value2): lookup = self.get_lookup_(location, PairPosBuilder) - v1 = makeOpenTypeValueRecord(value1, pairPosContext=True) - v2 = makeOpenTypeValueRecord(value2, pairPosContext=True) + v1 = self.makeOpenTypeValueRecord(location, value1, pairPosContext=True) + v2 = self.makeOpenTypeValueRecord(location, value2, pairPosContext=True) lookup.addClassPair(location, glyphclass1, v1, glyphclass2, v2) def add_subtable_break(self, location): @@ -1360,8 +1381,8 @@ def add_subtable_break(self, location): def add_specific_pair_pos(self, location, glyph1, value1, glyph2, value2): lookup = self.get_lookup_(location, PairPosBuilder) - v1 = makeOpenTypeValueRecord(value1, pairPosContext=True) - v2 = makeOpenTypeValueRecord(value2, pairPosContext=True) + v1 = self.makeOpenTypeValueRecord(location, value1, pairPosContext=True) + v2 = self.makeOpenTypeValueRecord(location, value2, pairPosContext=True) lookup.addGlyphPair(location, glyph1, v1, glyph2, v2) def add_single_pos(self, location, prefix, suffix, pos, forceChain): @@ -1370,7 +1391,7 @@ def add_single_pos(self, location, prefix, suffix, pos, forceChain): else: lookup = self.get_lookup_(location, SinglePosBuilder) for glyphs, value in pos: - otValueRecord = makeOpenTypeValueRecord(value, pairPosContext=False) + otValueRecord = self.makeOpenTypeValueRecord(location, value, pairPosContext=False) for glyph in glyphs: try: lookup.add_pos(location, glyph, otValueRecord) @@ -1388,7 +1409,7 @@ def add_single_pos_chained_(self, location, prefix, suffix, pos): if value is None: subs.append(None) continue - otValue = makeOpenTypeValueRecord(value, pairPosContext=False) + otValue = self.makeOpenTypeValueRecord(location, value, pairPosContext=False) sub = chain.find_chainable_single_pos(targets, glyphs, otValue) if sub is None: sub = self.get_chained_lookup_(location, SinglePosBuilder) @@ -1445,37 +1466,76 @@ def add_hhea_field(self, key, value): def add_vhea_field(self, key, value): self.vhea_[key] = value + def makeOpenTypeAnchor(self, location, anchor): + """ast.Anchor --> otTables.Anchor""" + if anchor is None: + return None + variable = False + deviceX, deviceY = None, None + if anchor.xDeviceTable is not None: + deviceX = otl.buildDevice(dict(anchor.xDeviceTable)) + if anchor.yDeviceTable is not None: + deviceY = otl.buildDevice(dict(anchor.yDeviceTable)) + for dim in ("x", "y"): + if not isinstance(getattr(anchor, dim), VariableScalar): + continue + if getattr(anchor, dim+"DeviceTable") is not None: + raise FeatureLibError("Can't define a device coordinate and variable scalar", location) + if not self.varstorebuilder: + raise FeatureLibError("Can't define a variable scalar in a non-variable font", location) + varscalar = getattr(anchor,dim) + varscalar.axes = self.axes + default, index = varscalar.add_to_variation_store(self.varstorebuilder) + setattr(anchor, dim, default) + if index is not None and index != 0xFFFFFFFF: + if dim == "x": + deviceX = buildVarDevTable(index) + else: + deviceY = buildVarDevTable(index) + variable = True -def makeOpenTypeAnchor(anchor): - """ast.Anchor --> otTables.Anchor""" - if anchor is None: - return None - deviceX, deviceY = None, None - if anchor.xDeviceTable is not None: - deviceX = otl.buildDevice(dict(anchor.xDeviceTable)) - if anchor.yDeviceTable is not None: - deviceY = otl.buildDevice(dict(anchor.yDeviceTable)) - return otl.buildAnchor(anchor.x, anchor.y, anchor.contourpoint, deviceX, deviceY) + otlanchor = otl.buildAnchor(anchor.x, anchor.y, anchor.contourpoint, deviceX, deviceY) + if variable: + otlanchor.Format = 3 + return otlanchor + _VALUEREC_ATTRS = { + name[0].lower() + name[1:]: (name, isDevice) + for _, name, isDevice, _ in otBase.valueRecordFormat + if not name.startswith("Reserved") + } -_VALUEREC_ATTRS = { - name[0].lower() + name[1:]: (name, isDevice) - for _, name, isDevice, _ in otBase.valueRecordFormat - if not name.startswith("Reserved") -} + def makeOpenTypeValueRecord(self, location, v, pairPosContext): + """ast.ValueRecord --> otBase.ValueRecord""" + if not v: + return None -def makeOpenTypeValueRecord(v, pairPosContext): - """ast.ValueRecord --> otBase.ValueRecord""" - if not v: - return None + vr = {} + variable = False + for astName, (otName, isDevice) in self._VALUEREC_ATTRS.items(): + val = getattr(v, astName, None) + if not val: + continue + if isDevice: + vr[otName] = otl.buildDevice(dict(val)) + elif isinstance(val, VariableScalar): + otDeviceName = otName[0:4] + "Device" + feaDeviceName = otDeviceName[0].lower() + otDeviceName[1:] + if getattr(v, feaDeviceName): + raise FeatureLibError("Can't define a device coordinate and variable scalar", location) + if not self.varstorebuilder: + raise FeatureLibError("Can't define a variable scalar in a non-variable font", location) + val.axes = self.axes + default, index = val.add_to_variation_store(self.varstorebuilder) + vr[otName] = default + if index is not None and index != 0xFFFFFFFF: + vr[otDeviceName] = buildVarDevTable(index) + variable = True + else: + vr[otName] = val - vr = {} - for astName, (otName, isDevice) in _VALUEREC_ATTRS.items(): - val = getattr(v, astName, None) - if val: - vr[otName] = otl.buildDevice(dict(val)) if isDevice else val - if pairPosContext and not vr: - vr = {"YAdvance": 0} if v.vertical else {"XAdvance": 0} - valRec = otl.buildValue(vr) - return valRec + if pairPosContext and not vr: + vr = {"YAdvance": 0} if v.vertical else {"XAdvance": 0} + valRec = otl.buildValue(vr) + return valRec diff --git a/Lib/fontTools/feaLib/variableScalar.py b/Lib/fontTools/feaLib/variableScalar.py index 217e41f655..87a1b9a044 100644 --- a/Lib/fontTools/feaLib/variableScalar.py +++ b/Lib/fontTools/feaLib/variableScalar.py @@ -26,30 +26,40 @@ def __repr__(self): def axes_dict(self): if not self.axes: raise ValueError(".axes must be defined on variable scalar before interpolating") - return {ax.tag: ax for ax in self.axes} + return {ax.axisTag: ax for ax in self.axes} def _normalized_location(self, location): + location = self.fix_location(location) normalized_location = {} for axtag in location.keys(): if axtag not in self.axes_dict: raise ValueError("Unknown axis %s in %s" % axtag, location) axis = self.axes_dict[axtag] normalized_location[axtag] = normalizeValue( - location[axtag], (axis.minimum, axis.default, axis.maximum) + location[axtag], (axis.minValue, axis.defaultValue, axis.maxValue) ) - for ax in self.axes: - if ax.tag not in normalized_location: - normalized_location[ax.tag] = 0 - return Location(normalized_location) + def fix_location(self, location): + for tag, axis in self.axes_dict.items(): + if tag not in location: + location[tag] = axis.defaultValue + return location + def add_value(self, location, value): + if self.axes: + location = self.fix_location(location) + self.values[Location(location)] = value + def fix_all_locations(self): + self.values = {self.fix_location(l): v for l,v in self.values.items()} + @property def default(self): - key = {ax.tag: ax.default for ax in self.axes} + self.fix_all_locations() + key = Location({ax.axisTag: ax.defaultValue for ax in self.axes}) if key not in self.values: raise ValueError("Default value could not be found") # I *guess* we could interpolate one, but I don't know how. diff --git a/Lib/fontTools/otlLib/builder.py b/Lib/fontTools/otlLib/builder.py index e9f61a8f6e..6ce18d1ff0 100644 --- a/Lib/fontTools/otlLib/builder.py +++ b/Lib/fontTools/otlLib/builder.py @@ -959,12 +959,19 @@ def build(self): positioning lookup. """ markClasses = self.buildMarkClasses_(self.marks) - marks = { - mark: (markClasses[mc], anchor) for mark, (mc, anchor) in self.marks.items() - } + marks = {} + for mark, (mc, anchor) in self.marks.items(): + if mc not in markClasses: + raise ValueError("Mark class %s not found for mark glyph %s" % (mc, mark)) + marks[mark] = (markClasses[mc], anchor) bases = {} for glyph, anchors in self.bases.items(): - bases[glyph] = {markClasses[mc]: anchor for (mc, anchor) in anchors.items()} + bases[glyph] = {} + for mc, anchor in anchors.items(): + if mc not in markClasses: + import IPython;IPython.embed() + raise ValueError("Mark class %s not found for base glyph %s" % (mc, mark)) + bases[glyph][markClasses[mc]] = anchor subtables = buildMarkBasePos(marks, bases, self.glyphMap) return self.buildLookup_(subtables) From c73de1aa79ca7bf7f1de35fb7368db1e18ebb099 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 18 Mar 2021 20:34:16 +0000 Subject: [PATCH 03/22] Variable scalar tests --- Tests/feaLib/builder_test.py | 13 ++- Tests/feaLib/data/variable_scalar_anchor.fea | 4 + Tests/feaLib/data/variable_scalar_anchor.ttx | 101 +++++++++++++++++ .../data/variable_scalar_valuerecord.fea | 5 + .../data/variable_scalar_valuerecord.ttx | 104 ++++++++++++++++++ 5 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 Tests/feaLib/data/variable_scalar_anchor.fea create mode 100644 Tests/feaLib/data/variable_scalar_anchor.ttx create mode 100644 Tests/feaLib/data/variable_scalar_valuerecord.fea create mode 100644 Tests/feaLib/data/variable_scalar_valuerecord.ttx diff --git a/Tests/feaLib/builder_test.py b/Tests/feaLib/builder_test.py index 97fc0f0b16..0fd0220ba0 100644 --- a/Tests/feaLib/builder_test.py +++ b/Tests/feaLib/builder_test.py @@ -2,10 +2,11 @@ from fontTools.feaLib.builder import Builder, addOpenTypeFeatures, \ addOpenTypeFeaturesFromString from fontTools.feaLib.error import FeatureLibError -from fontTools.ttLib import TTFont +from fontTools.ttLib import TTFont, newTable from fontTools.feaLib.parser import Parser from fontTools.feaLib import ast from fontTools.feaLib.lexer import Lexer +from fontTools.fontBuilder import addFvar import difflib from io import StringIO import os @@ -75,8 +76,14 @@ class BuilderTest(unittest.TestCase): SingleSubstSubtable aalt_chain_contextual_subst AlternateChained MultipleLookupsPerGlyph MultipleLookupsPerGlyph2 GSUB_6_formats GSUB_5_formats delete_glyph STAT_test STAT_test_elidedFallbackNameID + variable_scalar_valuerecord variable_scalar_anchor """.split() + VARFONT_AXES = [ + ("wght", 200, 200, 1000, "Weight"), + ("wdth", 100, 100, 200, "Width") + ] + def __init__(self, methodName): unittest.TestCase.__init__(self, methodName) # Python 3 renamed assertRaisesRegexp to assertRaisesRegex, @@ -138,6 +145,10 @@ def build(self, featureFile, tables=None): def check_feature_file(self, name): font = makeTTFont() + if name.startswith("variable_"): + font["name"] = newTable("name") + addFvar(font, self.VARFONT_AXES, []) + del font["name"] feapath = self.getpath("%s.fea" % name) addOpenTypeFeatures(font, feapath) self.expect_ttx(font, self.getpath("%s.ttx" % name)) diff --git a/Tests/feaLib/data/variable_scalar_anchor.fea b/Tests/feaLib/data/variable_scalar_anchor.fea new file mode 100644 index 0000000000..83d7df537d --- /dev/null +++ b/Tests/feaLib/data/variable_scalar_anchor.fea @@ -0,0 +1,4 @@ +languagesystem DFLT dflt; +feature kern { + pos cursive one ; +} kern; diff --git a/Tests/feaLib/data/variable_scalar_anchor.ttx b/Tests/feaLib/data/variable_scalar_anchor.ttx new file mode 100644 index 0000000000..6bb55691f6 --- /dev/null +++ b/Tests/feaLib/data/variable_scalar_anchor.ttx @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/feaLib/data/variable_scalar_valuerecord.fea b/Tests/feaLib/data/variable_scalar_valuerecord.fea new file mode 100644 index 0000000000..2ec5439301 --- /dev/null +++ b/Tests/feaLib/data/variable_scalar_valuerecord.fea @@ -0,0 +1,5 @@ +languagesystem DFLT dflt; +feature kern { + pos one 1; + pos two <0 (wght=200:12 wght=900:22 wght=900,wdth=150:42) 0 0>; +} kern; diff --git a/Tests/feaLib/data/variable_scalar_valuerecord.ttx b/Tests/feaLib/data/variable_scalar_valuerecord.ttx new file mode 100644 index 0000000000..338b72213c --- /dev/null +++ b/Tests/feaLib/data/variable_scalar_valuerecord.ttx @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 0c134121697c5231ef2258afc11aa9a6dee9a902 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Sat, 20 Mar 2021 07:28:24 +0000 Subject: [PATCH 04/22] Support variable scalars in pos A V (...); --- Lib/fontTools/feaLib/parser.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Lib/fontTools/feaLib/parser.py b/Lib/fontTools/feaLib/parser.py index bb51e91895..0b378787cc 100644 --- a/Lib/fontTools/feaLib/parser.py +++ b/Lib/fontTools/feaLib/parser.py @@ -1586,11 +1586,11 @@ def parse_device_(self): return result def is_next_value_(self): - return self.next_token_type_ is Lexer.NUMBER or self.next_token_ == "<" + return self.next_token_type_ is Lexer.NUMBER or self.next_token_ == "<" or self.next_token_ == "(" def parse_valuerecord_(self, vertical): - if self.next_token_type_ is Lexer.NUMBER: - number, location = self.expect_number_(), self.cur_token_location_ + if (self.next_token_type_ is Lexer.SYMBOL and self.next_token_ == "(") or self.next_token_type_ is Lexer.NUMBER: + number, location = self.expect_number_(variable=True), self.cur_token_location_ if vertical: val = self.ast.ValueRecord( yAdvance=number, vertical=vertical, location=location From a32d7aee3a83b1c7be8d922fd9816d17bedbcd0e Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Tue, 18 May 2021 16:49:41 +0100 Subject: [PATCH 05/22] Format bug --- Lib/fontTools/feaLib/variableScalar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/feaLib/variableScalar.py b/Lib/fontTools/feaLib/variableScalar.py index 87a1b9a044..5abbf049d1 100644 --- a/Lib/fontTools/feaLib/variableScalar.py +++ b/Lib/fontTools/feaLib/variableScalar.py @@ -33,7 +33,7 @@ def _normalized_location(self, location): normalized_location = {} for axtag in location.keys(): if axtag not in self.axes_dict: - raise ValueError("Unknown axis %s in %s" % axtag, location) + raise ValueError("Unknown axis %s in %s" % (axtag, location)) axis = self.axes_dict[axtag] normalized_location[axtag] = normalizeValue( location[axtag], (axis.minValue, axis.defaultValue, axis.maxValue) From be668878950c09a0308f5349fc9f3b1f6beaeeb0 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 20 May 2021 12:22:10 +0100 Subject: [PATCH 06/22] Useful property --- Lib/fontTools/feaLib/variableScalar.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Lib/fontTools/feaLib/variableScalar.py b/Lib/fontTools/feaLib/variableScalar.py index 5abbf049d1..f3e8ea4d08 100644 --- a/Lib/fontTools/feaLib/variableScalar.py +++ b/Lib/fontTools/feaLib/variableScalar.py @@ -22,6 +22,11 @@ def __repr__(self): items.append("%s:%i" % (loc, value)) return "("+(" ".join(items))+")" + @property + def does_vary(self): + values = list(self.values.values()) + return any(v != values[0] for v in values[1:]) + @property def axes_dict(self): if not self.axes: From 803dc38aa9463cad216b08f840d4dd474a8f61d7 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 20 May 2021 13:06:35 +0100 Subject: [PATCH 07/22] Add support for the conditionset statement --- Lib/fontTools/feaLib/ast.py | 27 +++++++++++++++++++++++++++ Lib/fontTools/feaLib/builder.py | 5 +++++ Lib/fontTools/feaLib/parser.py | 32 ++++++++++++++++++++++++++++++++ Tests/feaLib/parser_test.py | 18 ++++++++++++++++++ 4 files changed, 82 insertions(+) diff --git a/Lib/fontTools/feaLib/ast.py b/Lib/fontTools/feaLib/ast.py index 1ec8da0bdb..3d90d4d2c0 100644 --- a/Lib/fontTools/feaLib/ast.py +++ b/Lib/fontTools/feaLib/ast.py @@ -34,6 +34,7 @@ "ChainContextPosStatement", "ChainContextSubstStatement", "CharacterStatement", + "ConditionsetStatement", "CursivePosStatement", "ElidedFallbackName", "ElidedFallbackNameID", @@ -2033,3 +2034,29 @@ def asFea(self, res=""): res += f"location {self.tag} " res += f"{' '.join(str(i) for i in self.values)};\n" return res + + +class ConditionsetStatement(Statement): + """ + A variable layout conditionset + + Args: + name (str): the name of this conditionset + conditions (dict): a dictionary mapping axis tags to a + tuple of (min,max) userspace coordinates. + """ + + def __init__(self, name, conditions, location=None): + Statement.__init__(self, location) + self.name = name + self.conditions = conditions + + def build(self, builder): + builder.add_conditionset(self.name, self.conditions) + + def asFea(self, res=""): + res += f"conditionset {self.name} " + "{\n" + for tag, (minvalue, maxvalue) in self.conditions.items(): + res += f" {tag} {minvalue} {maxvalue};\n" + res += "}" + f" {self.name};\n" + return res diff --git a/Lib/fontTools/feaLib/builder.py b/Lib/fontTools/feaLib/builder.py index 6d01a976ce..7f0ac2b350 100644 --- a/Lib/fontTools/feaLib/builder.py +++ b/Lib/fontTools/feaLib/builder.py @@ -171,6 +171,8 @@ def __init__(self, font, featurefile): self.vhea_ = {} # for table 'STAT' self.stat_ = {} + # for conditionsets + self.conditionsets_ = {} def build(self, tables=None, debug=False): if self.parseTree is None: @@ -1466,6 +1468,9 @@ def add_hhea_field(self, key, value): def add_vhea_field(self, key, value): self.vhea_[key] = value + def add_conditionset(self, key, value): + self.conditionsets_[key] = value + def makeOpenTypeAnchor(self, location, anchor): """ast.Anchor --> otTables.Anchor""" if anchor is None: diff --git a/Lib/fontTools/feaLib/parser.py b/Lib/fontTools/feaLib/parser.py index 0b378787cc..9ca648e61e 100644 --- a/Lib/fontTools/feaLib/parser.py +++ b/Lib/fontTools/feaLib/parser.py @@ -102,6 +102,8 @@ def parse(self): statements.append(self.parse_markClass_()) elif self.is_cur_keyword_("feature"): statements.append(self.parse_feature_block_()) + elif self.is_cur_keyword_("conditionset"): + statements.append(self.parse_conditionset_()) elif self.is_cur_keyword_("table"): statements.append(self.parse_table_()) elif self.is_cur_keyword_("valueRecordDef"): @@ -1851,6 +1853,36 @@ def parse_FontRevision_(self): raise FeatureLibError("Font revision numbers must be positive", location) return self.ast.FontRevisionStatement(version, location=location) + def parse_conditionset_(self): + name = self.expect_name_() + + conditions = {} + self.expect_symbol_("{") + + while self.next_token_ != "}": + self.advance_lexer_() + if self.cur_token_type_ is not Lexer.NAME: + raise FeatureLibError("Expected an axis name", self.cur_token_location_) + + axis = self.cur_token_ + if axis in conditions: + raise FeatureLibError(f"Repeated condition for axis {axis}", self.cur_token_location_) + + min_value = self.expect_number_(variable=False) + max_value = self.expect_number_(variable=False) + self.expect_symbol_(";") + + conditions[axis] = (min_value, max_value) + + self.expect_symbol_("}") + + finalname = self.expect_name_() + if finalname != name: + raise FeatureLibError( + 'Expected "%s"' % name, self.cur_token_location_ + ) + return self.ast.ConditionsetStatement(name, conditions) + def parse_block_( self, block, vertical, stylisticset=None, size_feature=False, cv_feature=None ): diff --git a/Tests/feaLib/parser_test.py b/Tests/feaLib/parser_test.py index 3d11b878db..6319966f64 100644 --- a/Tests/feaLib/parser_test.py +++ b/Tests/feaLib/parser_test.py @@ -1848,6 +1848,24 @@ def test_valuerecord_device_value_out_of_range(self): "valueRecordDef <1 2 3 4 " " > foo;") + def test_conditionset(self): + doc = self.parse("conditionset heavy { wght 700 900; } heavy;") + value = doc.statements[0] + self.assertEqual(value.conditions["wght"], (700, 900)) + self.assertEqual(value.asFea(), "conditionset heavy {\n wght 700 900;\n} heavy;\n") + + doc = self.parse("conditionset heavy { wght 700 900; opsz 17 18;} heavy;") + value = doc.statements[0] + self.assertEqual(value.conditions["wght"], (700, 900)) + self.assertEqual(value.conditions["opsz"], (17, 18)) + self.assertEqual(value.asFea(), "conditionset heavy {\n wght 700 900;\n opsz 17 18;\n} heavy;\n") + + def test_conditionset_same_axis(self): + self.assertRaisesRegex( + FeatureLibError, r"Repeated condition for axis wght", + self.parse, + "conditionset heavy { wght 700 900; wght 100 200; } heavy;") + def test_languagesystem(self): [langsys] = self.parse("languagesystem latn DEU;").statements self.assertEqual(langsys.script, "latn") From 98215d33408ea2c997d623a4b91edf4a86e5b690 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 20 May 2021 14:25:30 +0100 Subject: [PATCH 08/22] Parse variation blocks --- Lib/fontTools/feaLib/ast.py | 33 +++++++++++++++++++++++++++++++++ Lib/fontTools/feaLib/parser.py | 23 ++++++++++++++++++----- Tests/feaLib/parser_test.py | 4 ++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/feaLib/ast.py b/Lib/fontTools/feaLib/ast.py index 3d90d4d2c0..06c333ebea 100644 --- a/Lib/fontTools/feaLib/ast.py +++ b/Lib/fontTools/feaLib/ast.py @@ -2060,3 +2060,36 @@ def asFea(self, res=""): res += f" {tag} {minvalue} {maxvalue};\n" res += "}" + f" {self.name};\n" return res + +class VariationBlock(Block): + """A named feature block.""" + + def __init__(self, name, conditionset, use_extension=False, location=None): + Block.__init__(self, location) + self.name, self.conditionset, self.use_extension = name, conditionset, use_extension + + def build(self, builder): + """Call the ``start_feature`` callback on the builder object, visit + all the statements in this feature, and then call ``end_feature``.""" + builder.start_feature(self.location, self.name, conditionset=self.conditionset) + # language exclude_dflt statements modify builder.features_ + # limit them to this block with temporary builder.features_ + features = builder.features_ + builder.features_ = {} + Block.build(self, builder) + for key, value in builder.features_.items(): + features.setdefault(key, []).extend(value) + builder.features_ = features + builder.end_feature() + + def asFea(self, indent=""): + res = indent + "variation %s " % self.name.strip() + res += self.conditionset + if self.use_extension: + res += "useExtension " + res += "{\n" + res += Block.asFea(self, indent=indent) + res += indent + "} %s;\n" % self.name.strip() + return res + + diff --git a/Lib/fontTools/feaLib/parser.py b/Lib/fontTools/feaLib/parser.py index 9ca648e61e..f7a5574fe8 100644 --- a/Lib/fontTools/feaLib/parser.py +++ b/Lib/fontTools/feaLib/parser.py @@ -104,6 +104,8 @@ def parse(self): statements.append(self.parse_feature_block_()) elif self.is_cur_keyword_("conditionset"): statements.append(self.parse_conditionset_()) + elif self.is_cur_keyword_("variation"): + statements.append(self.parse_feature_block_(variation=True)) elif self.is_cur_keyword_("table"): statements.append(self.parse_table_()) elif self.is_cur_keyword_("valueRecordDef"): @@ -1682,8 +1684,11 @@ def parse_languagesystem_(self): self.expect_symbol_(";") return self.ast.LanguageSystemStatement(script, language, location=location) - def parse_feature_block_(self): - assert self.cur_token_ == "feature" + def parse_feature_block_(self, variation=False): + if variation: + assert self.cur_token_ == "variation" + else: + assert self.cur_token_ == "feature" location = self.cur_token_location_ tag = self.expect_tag_() vertical = tag in {"vkrn", "vpal", "vhal", "valt"} @@ -1698,14 +1703,22 @@ def parse_feature_block_(self): elif tag == "size": size_feature = True + if variation: + conditionset = self.expect_name_() + use_extension = False if self.next_token_ == "useExtension": self.expect_keyword_("useExtension") use_extension = True - block = self.ast.FeatureBlock( - tag, use_extension=use_extension, location=location - ) + if variation: + block = self.ast.VariationBlock( + tag, conditionset, use_extension=use_extension, location=location + ) + else: + block = self.ast.FeatureBlock( + tag, use_extension=use_extension, location=location + ) self.parse_block_(block, vertical, stylisticset, size_feature, cv_feature) return block diff --git a/Tests/feaLib/parser_test.py b/Tests/feaLib/parser_test.py index 6319966f64..7d52cc2f02 100644 --- a/Tests/feaLib/parser_test.py +++ b/Tests/feaLib/parser_test.py @@ -1866,6 +1866,10 @@ def test_conditionset_same_axis(self): self.parse, "conditionset heavy { wght 700 900; wght 100 200; } heavy;") + def test_variation(self): + doc = self.parse("variation rvrn heavy { sub a by b; } rvrn;") + value = doc.statements[0] + def test_languagesystem(self): [langsys] = self.parse("languagesystem latn DEU;").statements self.assertEqual(langsys.script, "latn") From 46527aa2e2ffaeba6576c4374d2d2d1c31a6189d Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 20 May 2021 15:30:48 +0100 Subject: [PATCH 09/22] Round-trip FEA correctly --- Lib/fontTools/feaLib/ast.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/feaLib/ast.py b/Lib/fontTools/feaLib/ast.py index 06c333ebea..4759edede2 100644 --- a/Lib/fontTools/feaLib/ast.py +++ b/Lib/fontTools/feaLib/ast.py @@ -2054,11 +2054,11 @@ def __init__(self, name, conditions, location=None): def build(self, builder): builder.add_conditionset(self.name, self.conditions) - def asFea(self, res=""): - res += f"conditionset {self.name} " + "{\n" + def asFea(self, res="", indent=""): + res += indent + f"conditionset {self.name} " + "{\n" for tag, (minvalue, maxvalue) in self.conditions.items(): - res += f" {tag} {minvalue} {maxvalue};\n" - res += "}" + f" {self.name};\n" + res += indent + f" {tag} {minvalue} {maxvalue};\n" + res += indent + "}" + f" {self.name};\n" return res class VariationBlock(Block): @@ -2084,7 +2084,7 @@ def build(self, builder): def asFea(self, indent=""): res = indent + "variation %s " % self.name.strip() - res += self.conditionset + res += self.conditionset + " " if self.use_extension: res += "useExtension " res += "{\n" From 32ee4446b356a56dd230e88ac53499abca5c82bc Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 20 May 2021 15:31:13 +0100 Subject: [PATCH 10/22] Store the conditional features in a dictionary until we work out what to do with them --- Lib/fontTools/feaLib/ast.py | 13 +++++++++---- Lib/fontTools/feaLib/builder.py | 1 + 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Lib/fontTools/feaLib/ast.py b/Lib/fontTools/feaLib/ast.py index 4759edede2..0b7aa0f7e1 100644 --- a/Lib/fontTools/feaLib/ast.py +++ b/Lib/fontTools/feaLib/ast.py @@ -2071,14 +2071,19 @@ def __init__(self, name, conditionset, use_extension=False, location=None): def build(self, builder): """Call the ``start_feature`` callback on the builder object, visit all the statements in this feature, and then call ``end_feature``.""" - builder.start_feature(self.location, self.name, conditionset=self.conditionset) - # language exclude_dflt statements modify builder.features_ - # limit them to this block with temporary builder.features_ + builder.start_feature(self.location, self.name) + if self.conditionset != "NULL" and self.conditionset not in builder.conditionsets_: + raise FeatureLibError( + f"variation block used undefined conditionset {self.conditionset}", + self.location, + ) + features = builder.features_ builder.features_ = {} Block.build(self, builder) for key, value in builder.features_.items(): - features.setdefault(key, []).extend(value) + items = builder.conditionalFeatures_.setdefault(key,{}).setdefault(self.conditionset,[]) + items.extend(value) builder.features_ = features builder.end_feature() diff --git a/Lib/fontTools/feaLib/builder.py b/Lib/fontTools/feaLib/builder.py index 7f0ac2b350..86e2a28cba 100644 --- a/Lib/fontTools/feaLib/builder.py +++ b/Lib/fontTools/feaLib/builder.py @@ -134,6 +134,7 @@ def __init__(self, font, featurefile): self.lookup_locations = {"GSUB": {}, "GPOS": {}} self.features_ = {} # ('latn', 'DEU ', 'smcp') --> [LookupBuilder*] self.required_features_ = {} # ('latn', 'DEU ') --> 'scmp' + self.conditionalFeatures_ = {} # for feature 'aalt' self.aalt_features_ = [] # [(location, featureName)*], for 'aalt' self.aalt_location_ = None From 0cb89ccfec3089f7597ae0725274e4cfc534be8a Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Mon, 24 May 2021 13:41:17 +0100 Subject: [PATCH 11/22] Rearrange featureVars so we can do *really* raw feature builds (by lookup ID) See #2316 --- Lib/fontTools/varLib/featureVars.py | 31 +++++++++++++++++++---------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py index 45f3d83998..e5ec1041cf 100644 --- a/Lib/fontTools/varLib/featureVars.py +++ b/Lib/fontTools/varLib/featureVars.py @@ -44,8 +44,26 @@ def addFeatureVariations(font, conditionalSubstitutions, featureTag='rvrn'): # >>> f.save(dstPath) """ + + substitutions = overlayFeatureVariations(conditionalSubstitutions) + + # turn substitution dicts into tuples of tuples, so they are hashable + conditionalSubstitutions, allSubstitutions = makeSubstitutionsHashable(substitutions) + if "GSUB" not in font: + font["GSUB"] = buildGSUB() + + # setup lookups + lookupMap = buildSubstitutionLookups(font["GSUB"].table, allSubstitutions) + + # addFeatureVariationsRaw takes a list of + # ( {condition}, [ lookup indices ] ) + # so rearrange our lookups to match + conditionsAndLookups = [] + for conditionSet, substitutions in conditionalSubstitutions: + conditionsAndLookups.append((conditionSet, [lookupMap[s] for s in substitutions])) + addFeatureVariationsRaw(font, - overlayFeatureVariations(conditionalSubstitutions), + conditionsAndLookups, featureTag) def overlayFeatureVariations(conditionalSubstitutions): @@ -309,17 +327,10 @@ def addFeatureVariationsRaw(font, conditionalSubstitutions, featureTag='rvrn'): varFeatureIndices = [varFeatureIndex] - # setup lookups - - # turn substitution dicts into tuples of tuples, so they are hashable - conditionalSubstitutions, allSubstitutions = makeSubstitutionsHashable(conditionalSubstitutions) - - lookupMap = buildSubstitutionLookups(gsub, allSubstitutions) - axisIndices = {axis.axisTag: axisIndex for axisIndex, axis in enumerate(font["fvar"].axes)} featureVariationRecords = [] - for conditionSet, substitutions in conditionalSubstitutions: + for conditionSet, lookupIndices in conditionalSubstitutions: conditionTable = [] for axisTag, (minValue, maxValue) in sorted(conditionSet.items()): if minValue > maxValue: @@ -328,8 +339,6 @@ def addFeatureVariationsRaw(font, conditionalSubstitutions, featureTag='rvrn'): ) ct = buildConditionTable(axisIndices[axisTag], minValue, maxValue) conditionTable.append(ct) - - lookupIndices = [lookupMap[subst] for subst in substitutions] records = [] for varFeatureIndex in varFeatureIndices: existingLookupIndices = gsub.FeatureList.FeatureRecord[varFeatureIndex].Feature.LookupListIndex From 699ffe0110ca25e75b55b493c8d231bc44e5399c Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Mon, 24 May 2021 15:12:56 +0100 Subject: [PATCH 12/22] Oops, leftover when testing something else --- Lib/fontTools/otlLib/builder.py | 1 - 1 file changed, 1 deletion(-) diff --git a/Lib/fontTools/otlLib/builder.py b/Lib/fontTools/otlLib/builder.py index 6ce18d1ff0..db4cb6def9 100644 --- a/Lib/fontTools/otlLib/builder.py +++ b/Lib/fontTools/otlLib/builder.py @@ -969,7 +969,6 @@ def build(self): bases[glyph] = {} for mc, anchor in anchors.items(): if mc not in markClasses: - import IPython;IPython.embed() raise ValueError("Mark class %s not found for base glyph %s" % (mc, mark)) bases[glyph][markClasses[mc]] = anchor subtables = buildMarkBasePos(marks, bases, self.glyphMap) From 087848d06d0bc5be84076609713f333b5cd0c164 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Mon, 24 May 2021 15:15:13 +0100 Subject: [PATCH 13/22] A more generic interface, taking either GSUB or GPOS table --- Lib/fontTools/varLib/featureVars.py | 32 ++++++++++++----------------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py index e5ec1041cf..4542c98816 100644 --- a/Lib/fontTools/varLib/featureVars.py +++ b/Lib/fontTools/varLib/featureVars.py @@ -62,7 +62,7 @@ def addFeatureVariations(font, conditionalSubstitutions, featureTag='rvrn'): for conditionSet, substitutions in conditionalSubstitutions: conditionsAndLookups.append((conditionSet, [lookupMap[s] for s in substitutions])) - addFeatureVariationsRaw(font, + addFeatureVariationsRaw(font, font["GSUB"].table, conditionsAndLookups, featureTag) @@ -279,7 +279,7 @@ def cleanupBox(box): # Low level implementation # -def addFeatureVariationsRaw(font, conditionalSubstitutions, featureTag='rvrn'): +def addFeatureVariationsRaw(font, table, conditionalSubstitutions, featureTag='rvrn'): """Low level implementation of addFeatureVariations that directly models the possibilities of the FeatureVariations table.""" @@ -291,31 +291,25 @@ def addFeatureVariationsRaw(font, conditionalSubstitutions, featureTag='rvrn'): # make lookups # add feature variations # + if table.Version < 0x00010001: + table.Version = 0x00010001 # allow table.FeatureVariations - if "GSUB" not in font: - font["GSUB"] = buildGSUB() - - gsub = font["GSUB"].table - - if gsub.Version < 0x00010001: - gsub.Version = 0x00010001 # allow gsub.FeatureVariations - - gsub.FeatureVariations = None # delete any existing FeatureVariations + table.FeatureVariations = None # delete any existing FeatureVariations varFeatureIndices = [] - for index, feature in enumerate(gsub.FeatureList.FeatureRecord): + for index, feature in enumerate(table.FeatureList.FeatureRecord): if feature.FeatureTag == featureTag: varFeatureIndices.append(index) if not varFeatureIndices: varFeature = buildFeatureRecord(featureTag, []) - gsub.FeatureList.FeatureRecord.append(varFeature) - gsub.FeatureList.FeatureCount = len(gsub.FeatureList.FeatureRecord) + table.FeatureList.FeatureRecord.append(varFeature) + table.FeatureList.FeatureCount = len(table.FeatureList.FeatureRecord) - sortFeatureList(gsub) - varFeatureIndex = gsub.FeatureList.FeatureRecord.index(varFeature) + sortFeatureList(table) + varFeatureIndex = table.FeatureList.FeatureRecord.index(varFeature) - for scriptRecord in gsub.ScriptList.ScriptRecord: + for scriptRecord in table.ScriptList.ScriptRecord: if scriptRecord.Script.DefaultLangSys is None: raise VarLibError( "Feature variations require that the script " @@ -341,11 +335,11 @@ def addFeatureVariationsRaw(font, conditionalSubstitutions, featureTag='rvrn'): conditionTable.append(ct) records = [] for varFeatureIndex in varFeatureIndices: - existingLookupIndices = gsub.FeatureList.FeatureRecord[varFeatureIndex].Feature.LookupListIndex + existingLookupIndices = table.FeatureList.FeatureRecord[varFeatureIndex].Feature.LookupListIndex records.append(buildFeatureTableSubstitutionRecord(varFeatureIndex, existingLookupIndices + lookupIndices)) featureVariationRecords.append(buildFeatureVariationRecord(conditionTable, records)) - gsub.FeatureVariations = buildFeatureVariations(featureVariationRecords) + table.FeatureVariations = buildFeatureVariations(featureVariationRecords) # From 0de9ce6ce1f6abf82a8d0c14fe0e4d16ffe0c4fa Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Mon, 24 May 2021 15:16:29 +0100 Subject: [PATCH 14/22] Normalize condition sets when storing them --- Lib/fontTools/feaLib/builder.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Lib/fontTools/feaLib/builder.py b/Lib/fontTools/feaLib/builder.py index 86e2a28cba..45c21a1f11 100644 --- a/Lib/fontTools/feaLib/builder.py +++ b/Lib/fontTools/feaLib/builder.py @@ -33,6 +33,7 @@ from fontTools.otlLib.error import OpenTypeLibError from fontTools.varLib.varStore import OnlineVarStoreBuilder from fontTools.varLib.builder import buildVarDevTable +from fontTools.varLib.models import normalizeValue from collections import defaultdict import itertools from io import StringIO @@ -1470,6 +1471,25 @@ def add_vhea_field(self, key, value): self.vhea_[key] = value def add_conditionset(self, key, value): + if not "fvar" in self.font: + raise FeatureLibError( + "Cannot add feature variations to a font without an 'fvar' table" + ) + + # Normalize + axisMap = { + axis.axisTag: (axis.minValue, axis.defaultValue, axis.maxValue) + for axis in self.font["fvar"].axes + } + + value = { + tag: ( + normalizeValue(bottom, axisMap[tag]), + normalizeValue(top, axisMap[tag]), + ) + for tag, (bottom, top) in value.items() + } + self.conditionsets_[key] = value def makeOpenTypeAnchor(self, location, anchor): From 0031326024b39d8e1ec2480b3383f833c731dc01 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Mon, 24 May 2021 15:19:07 +0100 Subject: [PATCH 15/22] feature_variations_ is a better name than conditionalFeatures_ --- Lib/fontTools/feaLib/ast.py | 2 +- Lib/fontTools/feaLib/builder.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/feaLib/ast.py b/Lib/fontTools/feaLib/ast.py index 0b7aa0f7e1..231494aa2e 100644 --- a/Lib/fontTools/feaLib/ast.py +++ b/Lib/fontTools/feaLib/ast.py @@ -2082,7 +2082,7 @@ def build(self, builder): builder.features_ = {} Block.build(self, builder) for key, value in builder.features_.items(): - items = builder.conditionalFeatures_.setdefault(key,{}).setdefault(self.conditionset,[]) + items = builder.feature_variations_.setdefault(key,{}).setdefault(self.conditionset,[]) items.extend(value) builder.features_ = features builder.end_feature() diff --git a/Lib/fontTools/feaLib/builder.py b/Lib/fontTools/feaLib/builder.py index 45c21a1f11..822236ecc0 100644 --- a/Lib/fontTools/feaLib/builder.py +++ b/Lib/fontTools/feaLib/builder.py @@ -135,7 +135,7 @@ def __init__(self, font, featurefile): self.lookup_locations = {"GSUB": {}, "GPOS": {}} self.features_ = {} # ('latn', 'DEU ', 'smcp') --> [LookupBuilder*] self.required_features_ = {} # ('latn', 'DEU ') --> 'scmp' - self.conditionalFeatures_ = {} + self.feature_variations_ = {} # for feature 'aalt' self.aalt_features_ = [] # [(location, featureName)*], for 'aalt' self.aalt_location_ = None From c87399be6d88562f0b6db1d420c023dd98b429a5 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Mon, 24 May 2021 15:22:57 +0100 Subject: [PATCH 16/22] Support building feature variations using the "condition" statement. --- Lib/fontTools/feaLib/ast.py | 2 + Lib/fontTools/feaLib/builder.py | 44 +++++++++++++- Tests/feaLib/builder_test.py | 2 +- Tests/feaLib/data/variable_conditionset.fea | 13 +++++ Tests/feaLib/data/variable_conditionset.ttx | 63 +++++++++++++++++++++ 5 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 Tests/feaLib/data/variable_conditionset.fea create mode 100644 Tests/feaLib/data/variable_conditionset.ttx diff --git a/Lib/fontTools/feaLib/ast.py b/Lib/fontTools/feaLib/ast.py index 231494aa2e..13d002e319 100644 --- a/Lib/fontTools/feaLib/ast.py +++ b/Lib/fontTools/feaLib/ast.py @@ -2084,6 +2084,8 @@ def build(self, builder): for key, value in builder.features_.items(): items = builder.feature_variations_.setdefault(key,{}).setdefault(self.conditionset,[]) items.extend(value) + if key not in features: + features[key] = [] # Ensure we make a feature record builder.features_ = features builder.end_feature() diff --git a/Lib/fontTools/feaLib/builder.py b/Lib/fontTools/feaLib/builder.py index 822236ecc0..84ff066ef3 100644 --- a/Lib/fontTools/feaLib/builder.py +++ b/Lib/fontTools/feaLib/builder.py @@ -33,6 +33,7 @@ from fontTools.otlLib.error import OpenTypeLibError from fontTools.varLib.varStore import OnlineVarStoreBuilder from fontTools.varLib.builder import buildVarDevTable +from fontTools.varLib.featureVars import addFeatureVariationsRaw from fontTools.varLib.models import normalizeValue from collections import defaultdict import itertools @@ -210,6 +211,8 @@ def build(self, tables=None, debug=False): if tag not in tables: continue table = self.makeTable(tag) + if self.feature_variations_: + self.makeFeatureVariations(table, tag) if ( table.ScriptList.ScriptCount > 0 or table.FeatureList.FeatureCount > 0 @@ -873,7 +876,8 @@ def makeTable(self, tag): ) size_feature = tag == "GPOS" and feature_tag == "size" - if len(lookup_indices) == 0 and not size_feature: + force_feature = self.any_feature_variations(feature_tag, tag) + if len(lookup_indices) == 0 and not size_feature and not force_feature: continue for ix in lookup_indices: @@ -939,6 +943,42 @@ def makeTable(self, tag): table.LookupList.LookupCount = len(table.LookupList.Lookup) return table + def makeFeatureVariations(self, table, table_tag): + feature_vars = {} + has_any_variations = False + # Sort out which lookups to build, gather their indices + for ( + script_, + language, + feature_tag, + ), variations in self.feature_variations_.items(): + feature_vars[feature_tag] = [] + for conditionset, builders in variations.items(): + raw_conditionset = self.conditionsets_[conditionset] + indices = [] + for b in builders: + if b.table != table_tag: + continue + assert b.lookup_index is not None + indices.append(b.lookup_index) + has_any_variations = True + feature_vars[feature_tag].append((raw_conditionset, indices)) + + if has_any_variations: + for feature_tag, conditions_and_lookups in feature_vars.items(): + addFeatureVariationsRaw( + self.font, table, conditions_and_lookups, feature_tag + ) + + def any_feature_variations(self, feature_tag, table_tag): + for (_, _, feature), variations in self.feature_variations_.items(): + if feature != feature_tag: + continue + for conditionset, builders in variations.items(): + if any(b.table == table_tag for b in builders): + return True + return False + def get_lookup_name_(self, lookup): rev = {v: k for k, v in self.named_lookups_.items()} if lookup in rev: @@ -1479,7 +1519,7 @@ def add_conditionset(self, key, value): # Normalize axisMap = { axis.axisTag: (axis.minValue, axis.defaultValue, axis.maxValue) - for axis in self.font["fvar"].axes + for axis in self.axes } value = { diff --git a/Tests/feaLib/builder_test.py b/Tests/feaLib/builder_test.py index 0fd0220ba0..40d46f0b09 100644 --- a/Tests/feaLib/builder_test.py +++ b/Tests/feaLib/builder_test.py @@ -76,7 +76,7 @@ class BuilderTest(unittest.TestCase): SingleSubstSubtable aalt_chain_contextual_subst AlternateChained MultipleLookupsPerGlyph MultipleLookupsPerGlyph2 GSUB_6_formats GSUB_5_formats delete_glyph STAT_test STAT_test_elidedFallbackNameID - variable_scalar_valuerecord variable_scalar_anchor + variable_scalar_valuerecord variable_scalar_anchor variable_conditionset """.split() VARFONT_AXES = [ diff --git a/Tests/feaLib/data/variable_conditionset.fea b/Tests/feaLib/data/variable_conditionset.fea new file mode 100644 index 0000000000..7009c62bdb --- /dev/null +++ b/Tests/feaLib/data/variable_conditionset.fea @@ -0,0 +1,13 @@ +languagesystem DFLT dflt; + +lookup symbols_heavy { + sub a by b; +} symbols_heavy; + +conditionset heavy { + wght 700 900; +} heavy; + +variation rvrn heavy { + lookup symbols_heavy; +} rvrn; diff --git a/Tests/feaLib/data/variable_conditionset.ttx b/Tests/feaLib/data/variable_conditionset.ttx new file mode 100644 index 0000000000..208a5bff06 --- /dev/null +++ b/Tests/feaLib/data/variable_conditionset.ttx @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 389b0ea608a46a1db8148052fd9adf414b8ba05c Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Mon, 24 May 2021 16:17:27 +0100 Subject: [PATCH 17/22] Put count fields in ttx --- Tests/feaLib/data/variable_conditionset.ttx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tests/feaLib/data/variable_conditionset.ttx b/Tests/feaLib/data/variable_conditionset.ttx index 208a5bff06..52099e72ca 100644 --- a/Tests/feaLib/data/variable_conditionset.ttx +++ b/Tests/feaLib/data/variable_conditionset.ttx @@ -39,8 +39,10 @@ + + @@ -52,6 +54,7 @@ + From 6937cc3107f5448916c9a099999e2ca7c0b91e52 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Mon, 24 May 2021 16:21:44 +0100 Subject: [PATCH 18/22] Set count fields when building feature variations --- Lib/fontTools/varLib/featureVars.py | 4 ++++ Tests/feaLib/data/variable_conditionset.ttx | 1 + 2 files changed, 5 insertions(+) diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py index 4542c98816..21cb226d47 100644 --- a/Lib/fontTools/varLib/featureVars.py +++ b/Lib/fontTools/varLib/featureVars.py @@ -416,6 +416,7 @@ def buildFeatureVariations(featureVariationRecords): fv = ot.FeatureVariations() fv.Version = 0x00010000 fv.FeatureVariationRecord = featureVariationRecords + fv.FeatureVariationCount = len(featureVariationRecords) return fv @@ -434,9 +435,11 @@ def buildFeatureVariationRecord(conditionTable, substitutionRecords): fvr = ot.FeatureVariationRecord() fvr.ConditionSet = ot.ConditionSet() fvr.ConditionSet.ConditionTable = conditionTable + fvr.ConditionSet.ConditionCount = len(conditionTable) fvr.FeatureTableSubstitution = ot.FeatureTableSubstitution() fvr.FeatureTableSubstitution.Version = 0x00010000 fvr.FeatureTableSubstitution.SubstitutionRecord = substitutionRecords + fvr.FeatureTableSubstitution.SubstitutionCount = len(substitutionRecords) return fvr @@ -446,6 +449,7 @@ def buildFeatureTableSubstitutionRecord(featureIndex, lookupListIndices): ftsr.FeatureIndex = featureIndex ftsr.Feature = ot.Feature() ftsr.Feature.LookupListIndex = lookupListIndices + ftsr.Feature.LookupCount = len(lookupListIndices) return ftsr diff --git a/Tests/feaLib/data/variable_conditionset.ttx b/Tests/feaLib/data/variable_conditionset.ttx index 52099e72ca..18b156fac5 100644 --- a/Tests/feaLib/data/variable_conditionset.ttx +++ b/Tests/feaLib/data/variable_conditionset.ttx @@ -51,6 +51,7 @@ + From bd8ba64acbecc7de8e5d5f34af71f4c4309337f4 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Tue, 25 May 2021 16:41:11 +0100 Subject: [PATCH 19/22] Allow float values in condition sets. --- Lib/fontTools/feaLib/parser.py | 11 +++++++++-- Tests/feaLib/parser_test.py | 6 ++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/feaLib/parser.py b/Lib/fontTools/feaLib/parser.py index f7a5574fe8..50e3d65ae2 100644 --- a/Lib/fontTools/feaLib/parser.py +++ b/Lib/fontTools/feaLib/parser.py @@ -1881,8 +1881,15 @@ def parse_conditionset_(self): if axis in conditions: raise FeatureLibError(f"Repeated condition for axis {axis}", self.cur_token_location_) - min_value = self.expect_number_(variable=False) - max_value = self.expect_number_(variable=False) + if self.next_token_type_ is Lexer.FLOAT: + min_value = self.expect_float_() + elif self.next_token_type_ is Lexer.NUMBER: + min_value = self.expect_number_(variable=False) + + if self.next_token_type_ is Lexer.FLOAT: + max_value = self.expect_float_() + elif self.next_token_type_ is Lexer.NUMBER: + max_value = self.expect_number_(variable=False) self.expect_symbol_(";") conditions[axis] = (min_value, max_value) diff --git a/Tests/feaLib/parser_test.py b/Tests/feaLib/parser_test.py index 7d52cc2f02..5907064e7f 100644 --- a/Tests/feaLib/parser_test.py +++ b/Tests/feaLib/parser_test.py @@ -1866,6 +1866,12 @@ def test_conditionset_same_axis(self): self.parse, "conditionset heavy { wght 700 900; wght 100 200; } heavy;") + def test_conditionset_float(self): + doc = self.parse("conditionset heavy { wght 700.0 900.0; } heavy;") + value = doc.statements[0] + self.assertEqual(value.conditions["wght"], (700.0, 900.0)) + self.assertEqual(value.asFea(), "conditionset heavy {\n wght 700.0 900.0;\n} heavy;\n") + def test_variation(self): doc = self.parse("variation rvrn heavy { sub a by b; } rvrn;") value = doc.statements[0] From ddc7658f4a8b30e9b01b550420e0efb8692ed1d6 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 28 Oct 2021 11:35:28 +0100 Subject: [PATCH 20/22] Correct indentation --- Lib/fontTools/feaLib/ast.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/feaLib/ast.py b/Lib/fontTools/feaLib/ast.py index 13d002e319..811a0dc81e 100644 --- a/Lib/fontTools/feaLib/ast.py +++ b/Lib/fontTools/feaLib/ast.py @@ -2057,7 +2057,7 @@ def build(self, builder): def asFea(self, res="", indent=""): res += indent + f"conditionset {self.name} " + "{\n" for tag, (minvalue, maxvalue) in self.conditions.items(): - res += indent + f" {tag} {minvalue} {maxvalue};\n" + res += indent + SHIFT + f"{tag} {minvalue} {maxvalue};\n" res += indent + "}" + f" {self.name};\n" return res From 8f06c0e33a65cd489540ef2eb260854e543b403d Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 28 Oct 2021 11:35:50 +0100 Subject: [PATCH 21/22] Address feedback --- Lib/fontTools/feaLib/ast.py | 4 +++- Lib/fontTools/feaLib/variableScalar.py | 12 ++++++------ Tests/feaLib/data/variable_scalar_anchor.fea | 2 +- Tests/feaLib/data/variable_scalar_valuerecord.fea | 2 +- Tests/feaLib/parser_test.py | 14 +++++++------- 5 files changed, 18 insertions(+), 16 deletions(-) diff --git a/Lib/fontTools/feaLib/ast.py b/Lib/fontTools/feaLib/ast.py index 811a0dc81e..9d0c0870c1 100644 --- a/Lib/fontTools/feaLib/ast.py +++ b/Lib/fontTools/feaLib/ast.py @@ -2062,7 +2062,7 @@ def asFea(self, res="", indent=""): return res class VariationBlock(Block): - """A named feature block.""" + """A variation feature block, applicable in a given set of conditions.""" def __init__(self, name, conditionset, use_extension=False, location=None): Block.__init__(self, location) @@ -2078,6 +2078,8 @@ def build(self, builder): self.location, ) + # language exclude_dflt statements modify builder.features_ + # limit them to this block with temporary builder.features_ features = builder.features_ builder.features_ = {} Block.build(self, builder) diff --git a/Lib/fontTools/feaLib/variableScalar.py b/Lib/fontTools/feaLib/variableScalar.py index f3e8ea4d08..db19245815 100644 --- a/Lib/fontTools/feaLib/variableScalar.py +++ b/Lib/fontTools/feaLib/variableScalar.py @@ -1,9 +1,8 @@ from fontTools.varLib.models import VariationModel, normalizeValue -class Location(dict): - def __hash__(self): - return hash(frozenset(self)) +def Location(loc): + return tuple(sorted(loc.items())) class VariableScalar: @@ -18,7 +17,7 @@ def __init__(self, location_value={}): def __repr__(self): items = [] for location,value in self.values.items(): - loc = ",".join(["%s=%i" % (ax,loc) for ax,loc in location.items()]) + loc = ",".join(["%s=%i" % (ax,loc) for ax,loc in location]) items.append("%s:%i" % (loc, value)) return "("+(" ".join(items))+")" @@ -47,6 +46,7 @@ def _normalized_location(self, location): return Location(normalized_location) def fix_location(self, location): + location = dict(location) for tag, axis in self.axes_dict.items(): if tag not in location: location[tag] = axis.defaultValue @@ -59,7 +59,7 @@ def add_value(self, location, value): self.values[Location(location)] = value def fix_all_locations(self): - self.values = {self.fix_location(l): v for l,v in self.values.items()} + self.values = {Location(self.fix_location(l)): v for l,v in self.values.items()} @property def default(self): @@ -79,7 +79,7 @@ def value_at_location(self, location): @property def model(self): - locations = [self._normalized_location(k) for k in self.values.keys()] + locations = [dict(self._normalized_location(k)) for k in self.values.keys()] return VariationModel(locations) def get_deltas_and_supports(self): diff --git a/Tests/feaLib/data/variable_scalar_anchor.fea b/Tests/feaLib/data/variable_scalar_anchor.fea index 83d7df537d..c478798676 100644 --- a/Tests/feaLib/data/variable_scalar_anchor.fea +++ b/Tests/feaLib/data/variable_scalar_anchor.fea @@ -1,4 +1,4 @@ languagesystem DFLT dflt; feature kern { - pos cursive one ; + pos cursive one ; } kern; diff --git a/Tests/feaLib/data/variable_scalar_valuerecord.fea b/Tests/feaLib/data/variable_scalar_valuerecord.fea index 2ec5439301..bf9a26b7d8 100644 --- a/Tests/feaLib/data/variable_scalar_valuerecord.fea +++ b/Tests/feaLib/data/variable_scalar_valuerecord.fea @@ -1,5 +1,5 @@ languagesystem DFLT dflt; feature kern { pos one 1; - pos two <0 (wght=200:12 wght=900:22 wght=900,wdth=150:42) 0 0>; + pos two <0 (wght=200:12 wght=900:22 wdth=150,wght=900:42) 0 0>; } kern; diff --git a/Tests/feaLib/parser_test.py b/Tests/feaLib/parser_test.py index 5907064e7f..6344907c9d 100644 --- a/Tests/feaLib/parser_test.py +++ b/Tests/feaLib/parser_test.py @@ -175,10 +175,10 @@ def test_anchor_format_e_undefined(self): def test_anchor_variable_scalar(self): doc = self.parse( "feature test {" - " pos cursive A ;" + " pos cursive A ;" "} test;") anchor = doc.statements[0].statements[0].entryAnchor - self.assertEqual(anchor.asFea(), "") + self.assertEqual(anchor.asFea(), "") def test_anchordef(self): [foo] = self.parse("anchorDef 123 456 foo;").statements @@ -1813,9 +1813,9 @@ def test_valuerecord_format_d(self): self.assertEqual(value.asFea(), "") def test_valuerecord_variable_scalar(self): - doc = self.parse("feature test {valueRecordDef <0 (wght=200:-100 wght=900:-150 wght=900,wdth=150:-120) 0 0> foo;} test;") + doc = self.parse("feature test {valueRecordDef <0 (wght=200:-100 wght=900:-150 wdth=150,wght=900:-120) 0 0> foo;} test;") value = doc.statements[0].statements[0].value - self.assertEqual(value.asFea(), "<0 (wght=200:-100 wght=900:-150 wght=900,wdth=150:-120) 0 0>") + self.assertEqual(value.asFea(), "<0 (wght=200:-100 wght=900:-150 wdth=150,wght=900:-120) 0 0>") def test_valuerecord_named(self): doc = self.parse("valueRecordDef <1 2 3 4> foo;" @@ -1852,13 +1852,13 @@ def test_conditionset(self): doc = self.parse("conditionset heavy { wght 700 900; } heavy;") value = doc.statements[0] self.assertEqual(value.conditions["wght"], (700, 900)) - self.assertEqual(value.asFea(), "conditionset heavy {\n wght 700 900;\n} heavy;\n") + self.assertEqual(value.asFea(), "conditionset heavy {\n wght 700 900;\n} heavy;\n") doc = self.parse("conditionset heavy { wght 700 900; opsz 17 18;} heavy;") value = doc.statements[0] self.assertEqual(value.conditions["wght"], (700, 900)) self.assertEqual(value.conditions["opsz"], (17, 18)) - self.assertEqual(value.asFea(), "conditionset heavy {\n wght 700 900;\n opsz 17 18;\n} heavy;\n") + self.assertEqual(value.asFea(), "conditionset heavy {\n wght 700 900;\n opsz 17 18;\n} heavy;\n") def test_conditionset_same_axis(self): self.assertRaisesRegex( @@ -1870,7 +1870,7 @@ def test_conditionset_float(self): doc = self.parse("conditionset heavy { wght 700.0 900.0; } heavy;") value = doc.statements[0] self.assertEqual(value.conditions["wght"], (700.0, 900.0)) - self.assertEqual(value.asFea(), "conditionset heavy {\n wght 700.0 900.0;\n} heavy;\n") + self.assertEqual(value.asFea(), "conditionset heavy {\n wght 700.0 900.0;\n} heavy;\n") def test_variation(self): doc = self.parse("variation rvrn heavy { sub a by b; } rvrn;") From eab87ed34741c7d6bb80b4290a0f8c20fa5f4ab1 Mon Sep 17 00:00:00 2001 From: Simon Cozens Date: Thu, 28 Oct 2021 11:36:47 +0100 Subject: [PATCH 22/22] Run black on all touched files --- Lib/fontTools/feaLib/ast.py | 32 +- Lib/fontTools/feaLib/parser.py | 50 +- Lib/fontTools/feaLib/variableScalar.py | 14 +- Tests/feaLib/builder_test.py | 467 +++++++------ Tests/feaLib/parser_test.py | 910 +++++++++++++++---------- 5 files changed, 876 insertions(+), 597 deletions(-) diff --git a/Lib/fontTools/feaLib/ast.py b/Lib/fontTools/feaLib/ast.py index 9d0c0870c1..af23ecd425 100644 --- a/Lib/fontTools/feaLib/ast.py +++ b/Lib/fontTools/feaLib/ast.py @@ -1262,11 +1262,21 @@ def build(self, builder): 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 + self.location, + prefix, + glyph, + suffix, + self.replacement, + self.forceChain, ) else: builder.add_multiple_subst( - self.location, prefix, self.glyph, suffix, self.replacement, self.forceChain + self.location, + prefix, + self.glyph, + suffix, + self.replacement, + self.forceChain, ) def asFea(self, indent=""): @@ -2061,18 +2071,26 @@ def asFea(self, res="", indent=""): res += indent + "}" + f" {self.name};\n" return res + class VariationBlock(Block): """A variation feature block, applicable in a given set of conditions.""" def __init__(self, name, conditionset, use_extension=False, location=None): Block.__init__(self, location) - self.name, self.conditionset, self.use_extension = name, conditionset, use_extension + self.name, self.conditionset, self.use_extension = ( + name, + conditionset, + use_extension, + ) def build(self, builder): """Call the ``start_feature`` callback on the builder object, visit all the statements in this feature, and then call ``end_feature``.""" builder.start_feature(self.location, self.name) - if self.conditionset != "NULL" and self.conditionset not in builder.conditionsets_: + if ( + self.conditionset != "NULL" + and self.conditionset not in builder.conditionsets_ + ): raise FeatureLibError( f"variation block used undefined conditionset {self.conditionset}", self.location, @@ -2084,7 +2102,9 @@ def build(self, builder): builder.features_ = {} Block.build(self, builder) for key, value in builder.features_.items(): - items = builder.feature_variations_.setdefault(key,{}).setdefault(self.conditionset,[]) + items = builder.feature_variations_.setdefault(key, {}).setdefault( + self.conditionset, [] + ) items.extend(value) if key not in features: features[key] = [] # Ensure we make a feature record @@ -2100,5 +2120,3 @@ def asFea(self, indent=""): res += Block.asFea(self, indent=indent) res += indent + "} %s;\n" % self.name.strip() return res - - diff --git a/Lib/fontTools/feaLib/parser.py b/Lib/fontTools/feaLib/parser.py index 50e3d65ae2..6020b158ff 100644 --- a/Lib/fontTools/feaLib/parser.py +++ b/Lib/fontTools/feaLib/parser.py @@ -385,8 +385,7 @@ def parse_glyphclass_(self, accept_glyphname, accept_null=False): self.expect_symbol_("-") range_end = self.expect_cid_() self.check_glyph_name_in_glyph_set( - f"cid{range_start:05d}", - f"cid{range_end:05d}", + f"cid{range_start:05d}", f"cid{range_end:05d}", ) glyphs.add_cid_range( range_start, @@ -482,7 +481,7 @@ def parse_glyph_pattern_(self, vertical): raise FeatureLibError( "Positioning cannot be applied in the bactrack glyph sequence, " "before the marked glyph sequence.", - self.cur_token_location_ + self.cur_token_location_, ) marked_values = values[len(prefix) : len(prefix) + len(glyphs)] if any(marked_values): @@ -491,7 +490,7 @@ def parse_glyph_pattern_(self, vertical): "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_ + self.cur_token_location_, ) values = marked_values elif values and values[-1]: @@ -500,7 +499,7 @@ def parse_glyph_pattern_(self, vertical): "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_ + self.cur_token_location_, ) values = values[-1:] elif any(values): @@ -508,7 +507,7 @@ def parse_glyph_pattern_(self, vertical): "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_ + self.cur_token_location_, ) return (prefix, glyphs, lookups, values, suffix, hasMarks) @@ -1010,8 +1009,8 @@ def parse_size_parameters_(self): location = self.cur_token_location_ DesignSize = self.expect_decipoint_() SubfamilyID = self.expect_number_() - RangeStart = 0. - RangeEnd = 0. + RangeStart = 0.0 + RangeEnd = 0.0 if self.next_token_type_ in (Lexer.NUMBER, Lexer.FLOAT) or SubfamilyID != 0: RangeStart = self.expect_decipoint_() RangeEnd = self.expect_decipoint_() @@ -1590,11 +1589,20 @@ def parse_device_(self): return result def is_next_value_(self): - return self.next_token_type_ is Lexer.NUMBER or self.next_token_ == "<" or self.next_token_ == "(" + return ( + self.next_token_type_ is Lexer.NUMBER + or self.next_token_ == "<" + or self.next_token_ == "(" + ) def parse_valuerecord_(self, vertical): - if (self.next_token_type_ is Lexer.SYMBOL and self.next_token_ == "(") or self.next_token_type_ is Lexer.NUMBER: - number, location = self.expect_number_(variable=True), self.cur_token_location_ + if ( + self.next_token_type_ is Lexer.SYMBOL and self.next_token_ == "(" + ) or self.next_token_type_ is Lexer.NUMBER: + number, location = ( + self.expect_number_(variable=True), + self.cur_token_location_, + ) if vertical: val = self.ast.ValueRecord( yAdvance=number, vertical=vertical, location=location @@ -1879,7 +1887,9 @@ def parse_conditionset_(self): axis = self.cur_token_ if axis in conditions: - raise FeatureLibError(f"Repeated condition for axis {axis}", self.cur_token_location_) + raise FeatureLibError( + f"Repeated condition for axis {axis}", self.cur_token_location_ + ) if self.next_token_type_ is Lexer.FLOAT: min_value = self.expect_float_() @@ -1898,9 +1908,7 @@ def parse_conditionset_(self): finalname = self.expect_name_() if finalname != name: - raise FeatureLibError( - 'Expected "%s"' % name, self.cur_token_location_ - ) + raise FeatureLibError('Expected "%s"' % name, self.cur_token_location_) return self.ast.ConditionsetStatement(name, conditions) def parse_block_( @@ -2142,7 +2150,7 @@ def expect_number_(self, variable=False): raise FeatureLibError("Expected a number", self.cur_token_location_) def expect_variable_scalar_(self): - self.advance_lexer_() # "(" + self.advance_lexer_() # "(" scalar = VariableScalar() while True: if self.cur_token_type_ == Lexer.SYMBOL and self.cur_token_ == ")": @@ -2159,15 +2167,19 @@ def expect_master_(self): axis = self.cur_token_ self.advance_lexer_() if not (self.cur_token_type_ is Lexer.SYMBOL and self.cur_token_ == "="): - raise FeatureLibError("Expected an equals sign", self.cur_token_location_) + raise FeatureLibError( + "Expected an equals sign", self.cur_token_location_ + ) value = self.expect_number_() location[axis] = value if self.next_token_type_ is Lexer.NAME and self.next_token_[0] == ":": # Lexer has just read the value as a glyph name. We'll correct it later break self.advance_lexer_() - if not(self.cur_token_type_ is Lexer.SYMBOL and self.cur_token_ == ","): - raise FeatureLibError("Expected an comma or an equals sign", self.cur_token_location_) + if not (self.cur_token_type_ is Lexer.SYMBOL and self.cur_token_ == ","): + raise FeatureLibError( + "Expected an comma or an equals sign", self.cur_token_location_ + ) self.advance_lexer_() self.advance_lexer_() value = int(self.cur_token_[1:]) diff --git a/Lib/fontTools/feaLib/variableScalar.py b/Lib/fontTools/feaLib/variableScalar.py index db19245815..a286568eee 100644 --- a/Lib/fontTools/feaLib/variableScalar.py +++ b/Lib/fontTools/feaLib/variableScalar.py @@ -16,10 +16,10 @@ def __init__(self, location_value={}): def __repr__(self): items = [] - for location,value in self.values.items(): - loc = ",".join(["%s=%i" % (ax,loc) for ax,loc in location]) + for location, value in self.values.items(): + loc = ",".join(["%s=%i" % (ax, loc) for ax, loc in location]) items.append("%s:%i" % (loc, value)) - return "("+(" ".join(items))+")" + return "(" + (" ".join(items)) + ")" @property def does_vary(self): @@ -29,7 +29,9 @@ def does_vary(self): @property def axes_dict(self): if not self.axes: - raise ValueError(".axes must be defined on variable scalar before interpolating") + raise ValueError( + ".axes must be defined on variable scalar before interpolating" + ) return {ax.axisTag: ax for ax in self.axes} def _normalized_location(self, location): @@ -59,7 +61,9 @@ def add_value(self, location, value): self.values[Location(location)] = value def fix_all_locations(self): - self.values = {Location(self.fix_location(l)): v for l,v in self.values.items()} + self.values = { + Location(self.fix_location(l)): v for l, v in self.values.items() + } @property def default(self): diff --git a/Tests/feaLib/builder_test.py b/Tests/feaLib/builder_test.py index 40d46f0b09..16674fe367 100644 --- a/Tests/feaLib/builder_test.py +++ b/Tests/feaLib/builder_test.py @@ -1,6 +1,9 @@ from fontTools.misc.loggingTools import CapturingLogHandler -from fontTools.feaLib.builder import Builder, addOpenTypeFeatures, \ - addOpenTypeFeaturesFromString +from fontTools.feaLib.builder import ( + Builder, + addOpenTypeFeatures, + addOpenTypeFeaturesFromString, +) from fontTools.feaLib.error import FeatureLibError from fontTools.ttLib import TTFont, newTable from fontTools.feaLib.parser import Parser @@ -81,7 +84,7 @@ class BuilderTest(unittest.TestCase): VARFONT_AXES = [ ("wght", 200, 200, 1000, "Weight"), - ("wdth", 100, 100, 200, "Width") + ("wdth", 100, 100, 200, "Width"), ] def __init__(self, methodName): @@ -108,8 +111,7 @@ def temp_path(self, suffix): if not self.tempdir: self.tempdir = tempfile.mkdtemp() self.num_tempfiles += 1 - return os.path.join(self.tempdir, - "tmp%d%s" % (self.num_tempfiles, suffix)) + return os.path.join(self.tempdir, "tmp%d%s" % (self.num_tempfiles, suffix)) def read_ttx(self, path): lines = [] @@ -124,8 +126,21 @@ def read_ttx(self, path): def expect_ttx(self, font, expected_ttx, replace=None): path = self.temp_path(suffix=".ttx") - font.saveXML(path, tables=['head', 'name', 'BASE', 'GDEF', 'GSUB', - 'GPOS', 'OS/2', 'STAT', 'hhea', 'vhea']) + font.saveXML( + path, + tables=[ + "head", + "name", + "BASE", + "GDEF", + "GSUB", + "GPOS", + "OS/2", + "STAT", + "hhea", + "vhea", + ], + ) actual = self.read_ttx(path) expected = self.read_ttx(expected_ttx) if replace: @@ -134,7 +149,8 @@ def expect_ttx(self, font, expected_ttx, replace=None): expected[i] = expected[i].replace(k, v) if actual != expected: for line in difflib.unified_diff( - expected, actual, fromfile=expected_ttx, tofile=path): + expected, actual, fromfile=expected_ttx, tofile=path + ): sys.stderr.write(line) self.fail("TTX output is different from expected") @@ -155,7 +171,7 @@ def check_feature_file(self, name): # Check that: # 1) tables do compile (only G* tables as long as we have a mock font) # 2) dumping after save-reload yields the same TTX dump as before - for tag in ('GDEF', 'GSUB', 'GPOS'): + for tag in ("GDEF", "GSUB", "GPOS"): if tag in font: data = font[tag].compile(font) font[tag].decompile(data, font) @@ -164,11 +180,11 @@ def check_feature_file(self, name): debugttx = self.getpath("%s-debug.ttx" % name) if os.path.exists(debugttx): addOpenTypeFeatures(font, feapath, debug=True) - self.expect_ttx(font, debugttx, replace = {"__PATH__": feapath}) + self.expect_ttx(font, debugttx, replace={"__PATH__": feapath}) def check_fea2fea_file(self, name, base=None, parser=Parser): font = makeTTFont() - fname = (name + ".fea") if '.' not in name else name + fname = (name + ".fea") if "." not in name else name p = parser(self.getpath(fname), glyphNames=font.getGlyphOrder()) doc = p.parse() actual = self.normal_fea(doc.asFea().split("\n")) @@ -178,12 +194,16 @@ def check_fea2fea_file(self, name, base=None, parser=Parser): if expected != actual: fname = name.rsplit(".", 1)[0] + ".fea" for line in difflib.unified_diff( - expected, actual, - fromfile=fname + " (expected)", - tofile=fname + " (actual)"): - sys.stderr.write(line+"\n") - self.fail("Fea2Fea output is different from expected. " - "Generated:\n{}\n".format("\n".join(actual))) + expected, + actual, + fromfile=fname + " (expected)", + tofile=fname + " (actual)", + ): + sys.stderr.write(line + "\n") + self.fail( + "Fea2Fea output is different from expected. " + "Generated:\n{}\n".format("\n".join(actual)) + ) def normal_fea(self, lines): output = [] @@ -208,13 +228,14 @@ def normal_fea(self, lines): def test_alternateSubst_multipleSubstitutionsForSameGlyph(self): self.assertRaisesRegex( FeatureLibError, - "Already defined alternates for glyph \"A\"", + 'Already defined alternates for glyph "A"', self.build, "feature test {" " sub A from [A.alt1 A.alt2];" " sub B from [B.alt1 B.alt2 B.alt3];" " sub A from [A.alt1 A.alt2];" - "} test;") + "} test;", + ) def test_singleSubst_multipleIdenticalSubstitutionsForSameGlyph_info(self): logger = logging.getLogger("fontTools.feaLib.builder") @@ -224,19 +245,23 @@ def test_singleSubst_multipleIdenticalSubstitutionsForSameGlyph_info(self): " sub A by A.sc;" " sub B by B.sc;" " sub A by A.sc;" - "} test;") - captor.assertRegex('Removing duplicate single substitution from glyph "A" to "A.sc"') + "} test;" + ) + captor.assertRegex( + 'Removing duplicate single substitution from glyph "A" to "A.sc"' + ) def test_multipleSubst_multipleSubstitutionsForSameGlyph(self): self.assertRaisesRegex( FeatureLibError, - "Already defined substitution for glyph \"f_f_i\"", + 'Already defined substitution for glyph "f_f_i"', self.build, "feature test {" " sub f_f_i by f f i;" " sub c_t by c t;" " sub f_f_i by f_f i;" - "} test;") + "} test;", + ) def test_multipleSubst_multipleIdenticalSubstitutionsForSameGlyph_info(self): logger = logging.getLogger("fontTools.feaLib.builder") @@ -246,8 +271,11 @@ def test_multipleSubst_multipleIdenticalSubstitutionsForSameGlyph_info(self): " sub f_f_i by f f i;" " sub c_t by c t;" " sub f_f_i by f f i;" - "} test;") - captor.assertRegex(r"Removing duplicate multiple substitution from glyph \"f_f_i\" to \('f', 'f', 'i'\)") + "} test;" + ) + captor.assertRegex( + r"Removing duplicate multiple substitution from glyph \"f_f_i\" to \('f', 'f', 'i'\)" + ) def test_pairPos_redefinition_warning(self): # https://github.com/fonttools/fonttools/issues/1147 @@ -261,17 +289,18 @@ def test_pairPos_redefinition_warning(self): " pos yacute semicolon -70;" " enum pos @Y_LC semicolon -80;" " pos @Y_LC @SMALL_PUNC -100;" - "} kern;") + "} kern;" + ) captor.assertRegex("Already defined position for pair yacute semicolon") # the first definition prevails: yacute semicolon -70 st = font["GPOS"].table.LookupList.Lookup[0].SubTable[0] self.assertEqual(st.Coverage.glyphs[2], "yacute") - self.assertEqual(st.PairSet[2].PairValueRecord[0].SecondGlyph, - "semicolon") - self.assertEqual(vars(st.PairSet[2].PairValueRecord[0].Value1), - {"XAdvance": -70}) + self.assertEqual(st.PairSet[2].PairValueRecord[0].SecondGlyph, "semicolon") + self.assertEqual( + vars(st.PairSet[2].PairValueRecord[0].Value1), {"XAdvance": -70} + ) def test_singleSubst_multipleSubstitutionsForSameGlyph(self): self.assertRaisesRegex( @@ -281,127 +310,153 @@ def test_singleSubst_multipleSubstitutionsForSameGlyph(self): "feature test {" " sub [a-z] by [A.sc-Z.sc];" " sub e by e.fina;" - "} test;") + "} test;", + ) def test_singlePos_redefinition(self): self.assertRaisesRegex( FeatureLibError, - "Already defined different position for glyph \"A\"", - self.build, "feature test { pos A 123; pos A 456; } test;") + 'Already defined different position for glyph "A"', + self.build, + "feature test { pos A 123; pos A 456; } test;", + ) def test_feature_outside_aalt(self): self.assertRaisesRegex( FeatureLibError, 'Feature references are only allowed inside "feature aalt"', - self.build, "feature test { feature test; } test;") + self.build, + "feature test { feature test; } test;", + ) def test_feature_undefinedReference(self): self.assertRaisesRegex( - FeatureLibError, 'Feature none has not been defined', - self.build, "feature aalt { feature none; } aalt;") + FeatureLibError, + "Feature none has not been defined", + self.build, + "feature aalt { feature none; } aalt;", + ) def test_GlyphClassDef_conflictingClasses(self): self.assertRaisesRegex( - FeatureLibError, "Glyph X was assigned to a different class", + FeatureLibError, + "Glyph X was assigned to a different class", self.build, "table GDEF {" " GlyphClassDef [a b], [X], , ;" " GlyphClassDef [a b X], , , ;" - "} GDEF;") + "} GDEF;", + ) def test_languagesystem(self): 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') - self.assertEqual(builder.language_systems, - {('latn', 'FRA'), ('cyrl', 'RUS')}) + builder.add_language_system(None, "latn", "FRA") + builder.add_language_system(None, "cyrl", "RUS") + builder.start_feature(location=None, name="test") + self.assertEqual(builder.language_systems, {("latn", "FRA"), ("cyrl", "RUS")}) def test_languagesystem_duplicate(self): self.assertRaisesRegex( FeatureLibError, '"languagesystem cyrl RUS" has already been specified', - self.build, "languagesystem cyrl RUS; languagesystem cyrl RUS;") + self.build, + "languagesystem cyrl RUS; languagesystem cyrl RUS;", + ) def test_languagesystem_none_specified(self): builder = Builder(makeTTFont(), (None, None)) - builder.start_feature(location=None, name='test') - self.assertEqual(builder.language_systems, {('DFLT', 'dflt')}) + builder.start_feature(location=None, name="test") + self.assertEqual(builder.language_systems, {("DFLT", "dflt")}) def test_languagesystem_DFLT_dflt_not_first(self): self.assertRaisesRegex( FeatureLibError, - "If \"languagesystem DFLT dflt\" is present, " + 'If "languagesystem DFLT dflt" is present, ' "it must be the first of the languagesystem statements", - self.build, "languagesystem latn TRK; languagesystem DFLT dflt;") + self.build, + "languagesystem latn TRK; languagesystem DFLT dflt;", + ) def test_languagesystem_DFLT_not_preceding(self): self.assertRaisesRegex( FeatureLibError, - "languagesystems using the \"DFLT\" script tag must " + 'languagesystems using the "DFLT" script tag must ' "precede all other languagesystems", self.build, "languagesystem DFLT dflt; " "languagesystem latn dflt; " - "languagesystem DFLT fooo; " + "languagesystem DFLT fooo; ", ) def test_script(self): 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')}) + builder.start_feature(location=None, name="test") + builder.set_script(location=None, script="cyrl") + self.assertEqual(builder.language_systems, {("cyrl", "dflt")}) def test_script_in_aalt_feature(self): self.assertRaisesRegex( FeatureLibError, - "Script statements are not allowed within \"feature aalt\"", - self.build, "feature aalt { script latn; } aalt;") + 'Script statements are not allowed within "feature aalt"', + self.build, + "feature aalt { script latn; } aalt;", + ) def test_script_in_size_feature(self): self.assertRaisesRegex( FeatureLibError, - "Script statements are not allowed within \"feature size\"", - self.build, "feature size { script latn; } size;") + 'Script statements are not allowed within "feature size"', + self.build, + "feature size { script latn; } size;", + ) def test_script_in_standalone_lookup(self): self.assertRaisesRegex( FeatureLibError, "Script statements are not allowed within standalone lookup blocks", - self.build, "lookup test { script latn; } test;") + self.build, + "lookup test { script latn; } test;", + ) def test_language(self): 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') - builder.set_language(location=None, language='RUS ', - include_default=False, required=False) - self.assertEqual(builder.language_systems, {('cyrl', 'RUS ')}) - builder.set_language(location=None, language='BGR ', - include_default=True, required=False) - self.assertEqual(builder.language_systems, - {('cyrl', 'BGR ')}) - builder.start_feature(location=None, name='test2') - self.assertEqual(builder.language_systems, {('latn', 'FRA ')}) + builder.add_language_system(None, "latn", "FRA ") + builder.start_feature(location=None, name="test") + builder.set_script(location=None, script="cyrl") + builder.set_language( + location=None, language="RUS ", include_default=False, required=False + ) + self.assertEqual(builder.language_systems, {("cyrl", "RUS ")}) + builder.set_language( + location=None, language="BGR ", include_default=True, required=False + ) + self.assertEqual(builder.language_systems, {("cyrl", "BGR ")}) + builder.start_feature(location=None, name="test2") + self.assertEqual(builder.language_systems, {("latn", "FRA ")}) def test_language_in_aalt_feature(self): self.assertRaisesRegex( FeatureLibError, - "Language statements are not allowed within \"feature aalt\"", - self.build, "feature aalt { language FRA; } aalt;") + 'Language statements are not allowed within "feature aalt"', + self.build, + "feature aalt { language FRA; } aalt;", + ) def test_language_in_size_feature(self): self.assertRaisesRegex( FeatureLibError, - "Language statements are not allowed within \"feature size\"", - self.build, "feature size { language FRA; } size;") + 'Language statements are not allowed within "feature size"', + self.build, + "feature size { language FRA; } size;", + ) def test_language_in_standalone_lookup(self): self.assertRaisesRegex( FeatureLibError, "Language statements are not allowed within standalone lookup blocks", - self.build, "lookup test { language FRA; } test;") + self.build, + "lookup test { language FRA; } test;", + ) def test_language_required_duplicate(self): self.assertRaisesRegex( @@ -419,13 +474,16 @@ def test_language_required_duplicate(self): " script latn;" " language FRA required;" " substitute [a-z] by [A.sc-Z.sc];" - "} test;") + "} test;", + ) def test_lookup_already_defined(self): self.assertRaisesRegex( FeatureLibError, - "Lookup \"foo\" has already been defined", - self.build, "lookup foo {} foo; lookup foo {} foo;") + 'Lookup "foo" has already been defined', + self.build, + "lookup foo {} foo; lookup foo {} foo;", + ) def test_lookup_multiple_flags(self): self.assertRaisesRegex( @@ -438,7 +496,8 @@ def test_lookup_multiple_flags(self): " sub f i by f_i;" " lookupflag 2;" " sub f f i by f_f_i;" - "} foo;") + "} foo;", + ) def test_lookup_multiple_types(self): self.assertRaisesRegex( @@ -449,13 +508,16 @@ def test_lookup_multiple_types(self): "lookup foo {" " sub f f i by f_f_i;" " sub A from [A.alt1 A.alt2];" - "} foo;") + "} foo;", + ) def test_lookup_inside_feature_aalt(self): self.assertRaisesRegex( FeatureLibError, "Lookup blocks cannot be placed inside 'aalt' features", - self.build, "feature aalt {lookup L {} L;} aalt;") + self.build, + "feature aalt {lookup L {} L;} aalt;", + ) def test_chain_subst_refrences_GPOS_looup(self): self.assertRaisesRegex( @@ -465,7 +527,7 @@ def test_chain_subst_refrences_GPOS_looup(self): "lookup dummy { pos a 50; } dummy;" "feature test {" " sub a' lookup dummy b;" - "} test;" + "} test;", ) def test_chain_pos_refrences_GSUB_looup(self): @@ -476,203 +538,215 @@ def test_chain_pos_refrences_GSUB_looup(self): "lookup dummy { sub a by A; } dummy;" "feature test {" " pos a' lookup dummy b;" - "} test;" + "} test;", ) def test_STAT_elidedfallbackname_already_defined(self): self.assertRaisesRegex( FeatureLibError, - 'ElidedFallbackName is already set.', + "ElidedFallbackName is already set.", self.build, - 'table name {' + "table name {" ' nameid 256 "Roman"; ' - '} name;' - 'table STAT {' + "} name;" + "table STAT {" ' ElidedFallbackName { name "Roman"; };' - ' ElidedFallbackNameID 256;' - '} STAT;') + " ElidedFallbackNameID 256;" + "} STAT;", + ) def test_STAT_elidedfallbackname_set_twice(self): self.assertRaisesRegex( FeatureLibError, - 'ElidedFallbackName is already set.', + "ElidedFallbackName is already set.", self.build, - 'table name {' + "table name {" ' nameid 256 "Roman"; ' - '} name;' - 'table STAT {' + "} name;" + "table STAT {" ' ElidedFallbackName { name "Roman"; };' ' ElidedFallbackName { name "Italic"; };' - '} STAT;') + "} STAT;", + ) def test_STAT_elidedfallbacknameID_already_defined(self): self.assertRaisesRegex( FeatureLibError, - 'ElidedFallbackNameID is already set.', + "ElidedFallbackNameID is already set.", self.build, - 'table name {' + "table name {" ' nameid 256 "Roman"; ' - '} name;' - 'table STAT {' - ' ElidedFallbackNameID 256;' + "} name;" + "table STAT {" + " ElidedFallbackNameID 256;" ' ElidedFallbackName { name "Roman"; };' - '} STAT;') + "} STAT;", + ) def test_STAT_elidedfallbacknameID_not_in_name_table(self): self.assertRaisesRegex( FeatureLibError, - 'ElidedFallbackNameID 256 points to a nameID that does not ' + "ElidedFallbackNameID 256 points to a nameID that does not " 'exist in the "name" table', self.build, - 'table name {' + "table name {" ' nameid 257 "Roman"; ' - '} name;' - 'table STAT {' - ' ElidedFallbackNameID 256;' + "} name;" + "table STAT {" + " ElidedFallbackNameID 256;" ' DesignAxis opsz 1 { name "Optical Size"; };' - '} STAT;') + "} STAT;", + ) def test_STAT_design_axis_name(self): self.assertRaisesRegex( FeatureLibError, 'Expected "name"', self.build, - 'table name {' + "table name {" ' nameid 256 "Roman"; ' - '} name;' - 'table STAT {' + "} name;" + "table STAT {" ' ElidedFallbackName { name "Roman"; };' ' DesignAxis opsz 0 { badtag "Optical Size"; };' - '} STAT;') + "} STAT;", + ) def test_STAT_duplicate_design_axis_name(self): self.assertRaisesRegex( FeatureLibError, 'DesignAxis already defined for tag "opsz".', self.build, - 'table name {' + "table name {" ' nameid 256 "Roman"; ' - '} name;' - 'table STAT {' + "} name;" + "table STAT {" ' ElidedFallbackName { name "Roman"; };' ' DesignAxis opsz 0 { name "Optical Size"; };' ' DesignAxis opsz 1 { name "Optical Size"; };' - '} STAT;') + "} STAT;", + ) def test_STAT_design_axis_duplicate_order(self): self.assertRaisesRegex( FeatureLibError, "DesignAxis already defined for axis number 0.", self.build, - 'table name {' + "table name {" ' nameid 256 "Roman"; ' - '} name;' - 'table STAT {' + "} name;" + "table STAT {" ' ElidedFallbackName { name "Roman"; };' ' DesignAxis opsz 0 { name "Optical Size"; };' ' DesignAxis wdth 0 { name "Width"; };' - ' AxisValue {' - ' location opsz 8;' - ' location wdth 400;' + " AxisValue {" + " location opsz 8;" + " location wdth 400;" ' name "Caption";' - ' };' - '} STAT;') + " };" + "} STAT;", + ) def test_STAT_undefined_tag(self): self.assertRaisesRegex( FeatureLibError, - 'DesignAxis not defined for wdth.', + "DesignAxis not defined for wdth.", self.build, - 'table name {' + "table name {" ' nameid 256 "Roman"; ' - '} name;' - 'table STAT {' + "} name;" + "table STAT {" ' ElidedFallbackName { name "Roman"; };' ' DesignAxis opsz 0 { name "Optical Size"; };' - ' AxisValue { ' - ' location wdth 125; ' + " AxisValue { " + " location wdth 125; " ' name "Wide"; ' - ' };' - '} STAT;') + " };" + "} STAT;", + ) def test_STAT_axis_value_format4(self): self.assertRaisesRegex( FeatureLibError, - 'Axis tag wdth already defined.', + "Axis tag wdth already defined.", self.build, - 'table name {' + "table name {" ' nameid 256 "Roman"; ' - '} name;' - 'table STAT {' + "} name;" + "table STAT {" ' ElidedFallbackName { name "Roman"; };' ' DesignAxis opsz 0 { name "Optical Size"; };' ' DesignAxis wdth 1 { name "Width"; };' ' DesignAxis wght 2 { name "Weight"; };' - ' AxisValue { ' - ' location opsz 8; ' - ' location wdth 125; ' - ' location wdth 125; ' - ' location wght 500; ' + " AxisValue { " + " location opsz 8; " + " location wdth 125; " + " location wdth 125; " + " location wght 500; " ' name "Caption Medium Wide"; ' - ' };' - '} STAT;') + " };" + "} STAT;", + ) def test_STAT_duplicate_axis_value_record(self): # Test for Duplicate AxisValueRecords even when the definition order # is different. self.assertRaisesRegex( FeatureLibError, - 'An AxisValueRecord with these values is already defined.', + "An AxisValueRecord with these values is already defined.", self.build, - 'table name {' + "table name {" ' nameid 256 "Roman"; ' - '} name;' - 'table STAT {' + "} name;" + "table STAT {" ' ElidedFallbackName { name "Roman"; };' ' DesignAxis opsz 0 { name "Optical Size"; };' ' DesignAxis wdth 1 { name "Width"; };' - ' AxisValue {' - ' location opsz 8;' - ' location wdth 400;' + " AxisValue {" + " location opsz 8;" + " location wdth 400;" ' name "Caption";' - ' };' - ' AxisValue {' - ' location wdth 400;' - ' location opsz 8;' + " };" + " AxisValue {" + " location wdth 400;" + " location opsz 8;" ' name "Caption";' - ' };' - '} STAT;') + " };" + "} STAT;", + ) def test_STAT_axis_value_missing_location(self): self.assertRaisesRegex( FeatureLibError, 'Expected "Axis location"', self.build, - 'table name {' + "table name {" ' nameid 256 "Roman"; ' - '} name;' - 'table STAT {' + "} name;" + "table STAT {" ' ElidedFallbackName { name "Roman"; ' - '};' + "};" ' DesignAxis opsz 0 { name "Optical Size"; };' - ' AxisValue { ' + " AxisValue { " ' name "Wide"; ' - ' };' - '} STAT;') + " };" + "} STAT;", + ) def test_STAT_invalid_location_tag(self): self.assertRaisesRegex( FeatureLibError, - 'Tags cannot be longer than 4 characters', + "Tags cannot be longer than 4 characters", self.build, - 'table name {' + "table name {" ' nameid 256 "Roman"; ' - '} name;' - 'table STAT {' + "} name;" + "table STAT {" ' ElidedFallbackName { name "Roman"; ' ' name 3 1 0x0411 "ローマン"; }; ' ' DesignAxis width 0 { name "Width"; };' - '} STAT;') + "} STAT;", + ) def test_extensions(self): class ast_BaseClass(ast.MarkClass): @@ -690,7 +764,9 @@ def asFea(self, indent=""): for bcd in self.base.markClass.definitions: if res != "": res += "\n{}".format(indent) - res += "pos base {} {}".format(bcd.glyphs.asFea(), bcd.anchor.asFea()) + res += "pos base {} {}".format( + bcd.glyphs.asFea(), bcd.anchor.asFea() + ) for m in self.marks: res += " mark @{}".format(m.name) res += ";" @@ -703,6 +779,7 @@ def asFea(self, indent=""): class testAst(object): MarkBasePosStatement = ast_MarkBasePosStatement + def __getattr__(self, name): return getattr(ast, name) @@ -713,8 +790,9 @@ def parse_position_base_(self, enumerated, vertical): if enumerated: raise FeatureLibError( '"enumerate" is not allowed with ' - 'mark-to-base attachment positioning', - location) + "mark-to-base attachment positioning", + location, + ) base = self.parse_glyphclass_(accept_glyphname=True) if self.next_token_ == "<": marks = self.parse_anchor_marks_() @@ -725,11 +803,10 @@ def parse_position_base_(self, enumerated, vertical): m = self.expect_markClass_reference_() marks.append(m) self.expect_symbol_(";") - return self.ast.MarkBasePosStatement(base, marks, - location=location) + return self.ast.MarkBasePosStatement(base, marks, location=location) def parseBaseClass(self): - if not hasattr(self.doc_, 'baseClasses'): + if not hasattr(self.doc_, "baseClasses"): self.doc_.baseClasses = {} location = self.cur_token_location_ glyphs = self.parse_glyphclass_(accept_glyphname=True) @@ -741,37 +818,39 @@ def parseBaseClass(self): baseClass = ast_BaseClass(name) self.doc_.baseClasses[name] = baseClass self.glyphclasses_.define(name, baseClass) - bcdef = ast_BaseClassDefinition(baseClass, anchor, glyphs, - location=location) + bcdef = ast_BaseClassDefinition( + baseClass, anchor, glyphs, location=location + ) baseClass.addDefinition(bcdef) return bcdef - extensions = { - 'baseClass' : lambda s : s.parseBaseClass() - } + extensions = {"baseClass": lambda s: s.parseBaseClass()} ast = testAst() self.check_fea2fea_file( - "baseClass.feax", base="baseClass.fea", parser=testParser) + "baseClass.feax", base="baseClass.fea", parser=testParser + ) def test_markClass_same_glyph_redefined(self): self.assertRaisesRegex( FeatureLibError, "Glyph acute already defined", self.build, - "markClass [acute] @TOP_MARKS;"*2) + "markClass [acute] @TOP_MARKS;" * 2, + ) def test_markClass_same_glyph_multiple_classes(self): self.assertRaisesRegex( FeatureLibError, - 'Glyph uni0327 cannot be in both @ogonek and @cedilla', + "Glyph uni0327 cannot be in both @ogonek and @cedilla", self.build, "feature mark {" " markClass [uni0327 uni0328] @ogonek;" " pos base [a] mark @ogonek;" " markClass [uni0327] @cedilla;" " pos base [a] mark @cedilla;" - "} mark;") + "} mark;", + ) def test_build_specific_tables(self): features = "feature liga {sub f i by f_i;} liga;" @@ -793,7 +872,7 @@ def test_build_pre_parsed_ast_featurefile(self): def test_unsupported_subtable_break(self): logger = logging.getLogger("fontTools.otlLib.builder") - with CapturingLogHandler(logger, level='WARNING') as captor: + with CapturingLogHandler(logger, level="WARNING") as captor: self.build( "feature test {" " pos a 10;" @@ -824,10 +903,8 @@ def test_singlePos_multiplePositionsForSameGlyph(self): FeatureLibError, "Already defined different position for glyph", self.build, - "lookup foo {" - " pos A -45; " - " pos A 45; " - "} foo;") + "lookup foo {" " pos A -45; " " pos A 45; " "} foo;", + ) def test_pairPos_enumRuleOverridenBySinglePair_DEBUG(self): logger = logging.getLogger("fontTools.otlLib.builder") @@ -836,14 +913,14 @@ def test_pairPos_enumRuleOverridenBySinglePair_DEBUG(self): "feature test {" " enum pos A [V Y] -80;" " pos A V -75;" - "} test;") - captor.assertRegex('Already defined position for pair A V at') + "} test;" + ) + captor.assertRegex("Already defined position for pair A V at") def test_ignore_empty_lookup_block(self): # https://github.com/fonttools/fonttools/pull/2277 font = self.build( - "lookup EMPTY { ; } EMPTY;" - "feature ss01 { lookup EMPTY; } ss01;" + "lookup EMPTY { ; } EMPTY;" "feature ss01 { lookup EMPTY; } ss01;" ) assert "GPOS" not in font assert "GSUB" not in font @@ -854,8 +931,7 @@ def generate_feature_file_test(name): for name in BuilderTest.TEST_FEATURE_FILES: - setattr(BuilderTest, "test_FeatureFile_%s" % name, - generate_feature_file_test(name)) + setattr(BuilderTest, "test_FeatureFile_%s" % name, generate_feature_file_test(name)) def generate_fea2fea_file_test(name): @@ -863,8 +939,11 @@ def generate_fea2fea_file_test(name): for name in BuilderTest.TEST_FEATURE_FILES: - setattr(BuilderTest, "test_Fea2feaFile_{}".format(name), - generate_fea2fea_file_test(name)) + setattr( + BuilderTest, + "test_Fea2feaFile_{}".format(name), + generate_fea2fea_file_test(name), + ) if __name__ == "__main__": diff --git a/Tests/feaLib/parser_test.py b/Tests/feaLib/parser_test.py index 6344907c9d..fd9dea7002 100644 --- a/Tests/feaLib/parser_test.py +++ b/Tests/feaLib/parser_test.py @@ -14,8 +14,9 @@ def f(x): if len(x) == 1: return list(x)[0] else: - return '[%s]' % ' '.join(sorted(list(x))) - return ' '.join(f(g.glyphSet()) for g in glyphs) + return "[%s]" % " ".join(sorted(list(x))) + + return " ".join(f(g.glyphSet()) for g in glyphs) def mapping(s): @@ -30,7 +31,9 @@ def mapping(s): return dict(zip(b, c)) -GLYPHNAMES = (""" +GLYPHNAMES = ( + ( + """ .notdef space A B C D E F G H I J K L M N O P Q R S T U V W X Y Z A.sc B.sc C.sc D.sc E.sc F.sc G.sc H.sc I.sc J.sc K.sc L.sc M.sc N.sc O.sc P.sc Q.sc R.sc S.sc T.sc U.sc V.sc W.sc X.sc Y.sc Z.sc @@ -48,7 +51,10 @@ def mapping(s): cid00111 cid00222 comma endash emdash figuredash damma hamza c_d d.alt n.end s.end f_f -""").split() + ["foo.%d" % i for i in range(1, 200)] +""" + ).split() + + ["foo.%d" % i for i in range(1, 200)] +) class ParserTest(unittest.TestCase): @@ -60,7 +66,7 @@ def __init__(self, methodName): self.assertRaisesRegex = self.assertRaisesRegexp def test_glyphMap_deprecated(self): - glyphMap = {'a': 0, 'b': 1, 'c': 2} + glyphMap = {"a": 0, "b": 1, "c": 2} with warnings.catch_warnings(record=True) as w: warnings.simplefilter("always") parser = Parser(StringIO(), glyphMap=glyphMap) @@ -68,22 +74,28 @@ def test_glyphMap_deprecated(self): self.assertEqual(len(w), 1) self.assertEqual(w[-1].category, UserWarning) self.assertIn("deprecated", str(w[-1].message)) - self.assertEqual(parser.glyphNames_, {'a', 'b', 'c'}) + self.assertEqual(parser.glyphNames_, {"a", "b", "c"}) self.assertRaisesRegex( - TypeError, "mutually exclusive", - Parser, StringIO(), ("a",), glyphMap={"a": 0}) + TypeError, + "mutually exclusive", + Parser, + StringIO(), + ("a",), + glyphMap={"a": 0}, + ) self.assertRaisesRegex( - TypeError, "unsupported keyword argument", - Parser, StringIO(), foo="bar") + TypeError, "unsupported keyword argument", Parser, StringIO(), foo="bar" + ) def test_comments(self): doc = self.parse( """ # Initial feature test { sub A by B; # simple - } test;""") + } test;""" + ) c1 = doc.statements[0] c2 = doc.statements[1].statements[1] self.assertEqual(type(c1), ast.Comment) @@ -94,9 +106,11 @@ def test_comments(self): self.assertEqual(doc.statements[1].name, "test") def test_only_comments(self): - doc = self.parse("""\ + doc = self.parse( + """\ # Initial - """) + """ + ) c1 = doc.statements[0] self.assertEqual(type(c1), ast.Comment) self.assertEqual(c1.text, "# Initial") @@ -106,7 +120,8 @@ def test_anchor_format_a(self): doc = self.parse( "feature test {" " pos cursive A ;" - "} test;") + "} test;" + ) anchor = doc.statements[0].statements[0].entryAnchor self.assertEqual(type(anchor), ast.Anchor) self.assertEqual(anchor.x, 120) @@ -119,7 +134,8 @@ def test_anchor_format_b(self): doc = self.parse( "feature test {" " pos cursive A ;" - "} test;") + "} test;" + ) anchor = doc.statements[0].statements[0].entryAnchor self.assertEqual(type(anchor), ast.Anchor) self.assertEqual(anchor.x, 120) @@ -134,7 +150,8 @@ def test_anchor_format_c(self): " pos cursive A " " >" " ;" - "} test;") + "} test;" + ) anchor = doc.statements[0].statements[0].entryAnchor self.assertEqual(type(anchor), ast.Anchor) self.assertEqual(anchor.x, 120) @@ -147,7 +164,8 @@ def test_anchor_format_d(self): doc = self.parse( "feature test {" " pos cursive A ;" - "} test;") + "} test;" + ) anchor = doc.statements[0].statements[0].exitAnchor self.assertIsNone(anchor) @@ -156,7 +174,8 @@ def test_anchor_format_e(self): "feature test {" " anchorDef 120 -20 contourpoint 7 Foo;" " pos cursive A ;" - "} test;") + "} test;" + ) anchor = doc.statements[0].statements[1].entryAnchor self.assertEqual(type(anchor), ast.Anchor) self.assertEqual(anchor.x, 120) @@ -167,18 +186,25 @@ def test_anchor_format_e(self): def test_anchor_format_e_undefined(self): self.assertRaisesRegex( - FeatureLibError, 'Unknown anchor "UnknownName"', self.parse, + FeatureLibError, + 'Unknown anchor "UnknownName"', + self.parse, "feature test {" " position cursive A ;" - "} test;") + "} test;", + ) def test_anchor_variable_scalar(self): doc = self.parse( "feature test {" " pos cursive A ;" - "} test;") + "} test;" + ) anchor = doc.statements[0].statements[0].entryAnchor - self.assertEqual(anchor.asFea(), "") + self.assertEqual( + anchor.asFea(), + "", + ) def test_anchordef(self): [foo] = self.parse("anchorDef 123 456 foo;").statements @@ -211,8 +237,11 @@ def test_anonymous(self): def test_anon_missingBrace(self): self.assertRaisesRegex( - FeatureLibError, "Expected '} TEST;' to terminate anonymous block", - self.parse, "anon TEST { \n no end in sight") + FeatureLibError, + "Expected '} TEST;' to terminate anonymous block", + self.parse, + "anon TEST { \n no end in sight", + ) def test_attach(self): doc = self.parse("table GDEF {Attach [a e] 2;} GDEF;") @@ -230,8 +259,7 @@ def test_feature_block_useExtension(self): [liga] = self.parse("feature liga useExtension {} liga;").statements self.assertEqual(liga.name, "liga") self.assertTrue(liga.use_extension) - self.assertEqual(liga.asFea(), - "feature liga useExtension {\n \n} liga;\n") + self.assertEqual(liga.asFea(), "feature liga useExtension {\n \n} liga;\n") def test_feature_comment(self): [liga] = self.parse("feature liga { # Comment\n } liga;").statements @@ -247,12 +275,16 @@ def test_feature_reference(self): def test_FeatureNames_bad(self): self.assertRaisesRegex( - FeatureLibError, 'Expected "name"', - self.parse, "feature ss01 { featureNames { feature test; } ss01;") + FeatureLibError, + 'Expected "name"', + self.parse, + "feature ss01 { featureNames { feature test; } ss01;", + ) def test_FeatureNames_comment(self): [feature] = self.parse( - "feature ss01 { featureNames { # Comment\n }; } ss01;").statements + "feature ss01 { featureNames { # Comment\n }; } ss01;" + ).statements [featureNames] = feature.statements self.assertIsInstance(featureNames, ast.NestedBlock) [comment] = featureNames.statements @@ -261,7 +293,8 @@ def test_FeatureNames_comment(self): def test_FeatureNames_emptyStatements(self): [feature] = self.parse( - "feature ss01 { featureNames { ;;; }; } ss01;").statements + "feature ss01 { featureNames { ;;; }; } ss01;" + ).statements [featureNames] = feature.statements self.assertIsInstance(featureNames, ast.NestedBlock) self.assertEqual(featureNames.statements, []) @@ -274,8 +307,11 @@ def test_FontRevision(self): def test_FontRevision_negative(self): self.assertRaisesRegex( - FeatureLibError, "Font revision numbers must be positive", - self.parse, "table head {FontRevision -17.2;} head;") + FeatureLibError, + "Font revision numbers must be positive", + self.parse, + "table head {FontRevision -17.2;} head;", + ) def test_strict_glyph_name_check(self): self.parse("@bad = [a b ccc];", glyphNames=("a", "b", "ccc")) @@ -290,14 +326,19 @@ def test_glyphclass(self): def test_glyphclass_glyphNameTooLong(self): self.assertRaisesRegex( - FeatureLibError, "must not be longer than 63 characters", - self.parse, "@GlyphClass = [%s];" % ("G" * 64)) + FeatureLibError, + "must not be longer than 63 characters", + self.parse, + "@GlyphClass = [%s];" % ("G" * 64), + ) def test_glyphclass_bad(self): self.assertRaisesRegex( FeatureLibError, "Expected glyph name, glyph range, or glyph class reference", - self.parse, "@bad = [a 123];") + self.parse, + "@bad = [a 123];", + ) def test_glyphclass_duplicate(self): # makeotf accepts this, so we should too @@ -320,9 +361,11 @@ def test_glyphclass_from_markClass(self): "markClass [acute grave] @TOP_MARKS;" "markClass cedilla @BOTTOM_MARKS;" "@MARKS = [@TOP_MARKS @BOTTOM_MARKS ogonek];" - "@ALL = @MARKS;") - self.assertEqual(doc.statements[-1].glyphSet(), - ("acute", "grave", "cedilla", "ogonek")) + "@ALL = @MARKS;" + ) + self.assertEqual( + doc.statements[-1].glyphSet(), ("acute", "grave", "cedilla", "ogonek") + ) def test_glyphclass_range_cid(self): [gc] = self.parse(r"@GlyphClass = [\999-\1001];").statements @@ -333,7 +376,9 @@ def test_glyphclass_range_cid_bad(self): self.assertRaisesRegex( FeatureLibError, "Bad range: start should be less than limit", - self.parse, r"@bad = [\998-\995];") + self.parse, + r"@bad = [\998-\995];", + ) def test_glyphclass_range_uppercase(self): [gc] = self.parse("@swashes = [X.swash-Z.swash];").statements @@ -363,7 +408,9 @@ def test_glyphclass_ambiguous_dash_no_glyph_names(self): # https://github.com/fonttools/fonttools/issues/1768 glyphNames = () with CapturingLogHandler("fontTools.feaLib.parser", level="WARNING") as caplog: - [gc] = self.parse("@class = [A-foo.sc B-foo.sc C D];", glyphNames).statements + [gc] = self.parse( + "@class = [A-foo.sc B-foo.sc C D];", glyphNames + ).statements self.assertEqual(gc.glyphSet(), ("A-foo.sc", "B-foo.sc", "C", "D")) self.assertEqual(len(caplog.records), 2) caplog.assertRegex("Ambiguous glyph name that looks like a range:") @@ -372,8 +419,7 @@ def test_glyphclass_glyph_name_should_win_over_range(self): # The OpenType Feature File Specification v1.20 makes it clear # that if a dashed name could be interpreted either as a glyph name # or as a range, then the semantics should be the single dashed name. - glyphNames = ( - "A-foo.sc-C-foo.sc A-foo.sc B-foo.sc C-foo.sc".split()) + glyphNames = "A-foo.sc-C-foo.sc A-foo.sc B-foo.sc C-foo.sc".split() [gc] = self.parse("@range = [A-foo.sc-C-foo.sc];", glyphNames).statements self.assertEqual(gc.glyphSet(), ("A-foo.sc-C-foo.sc",)) @@ -383,7 +429,10 @@ def test_glyphclass_range_dash_ambiguous(self): FeatureLibError, 'Ambiguous glyph range "A-B-C"; ' 'please use "A - B-C" or "A-B - C" to clarify what you mean', - self.parse, r"@bad = [A-B-C];", glyphNames) + self.parse, + r"@bad = [A-B-C];", + glyphNames, + ) def test_glyphclass_range_digit1(self): [gc] = self.parse("@range = [foo.2-foo.5];").statements @@ -400,36 +449,50 @@ def test_glyphclass_range_digit3(self): def test_glyphclass_range_bad(self): self.assertRaisesRegex( FeatureLibError, - "Bad range: \"a\" and \"foobar\" should have the same length", - self.parse, "@bad = [a-foobar];") + 'Bad range: "a" and "foobar" should have the same length', + self.parse, + "@bad = [a-foobar];", + ) self.assertRaisesRegex( - FeatureLibError, "Bad range: \"A.swash-z.swash\"", - self.parse, "@bad = [A.swash-z.swash];") + FeatureLibError, + 'Bad range: "A.swash-z.swash"', + self.parse, + "@bad = [A.swash-z.swash];", + ) self.assertRaisesRegex( - FeatureLibError, "Start of range must be smaller than its end", - self.parse, "@bad = [B.swash-A.swash];") + FeatureLibError, + "Start of range must be smaller than its end", + self.parse, + "@bad = [B.swash-A.swash];", + ) self.assertRaisesRegex( - FeatureLibError, "Bad range: \"foo.1234-foo.9876\"", - self.parse, "@bad = [foo.1234-foo.9876];") + FeatureLibError, + 'Bad range: "foo.1234-foo.9876"', + self.parse, + "@bad = [foo.1234-foo.9876];", + ) def test_glyphclass_range_mixed(self): [gc] = self.parse("@range = [a foo.09-foo.11 X.sc-Z.sc];").statements - self.assertEqual(gc.glyphSet(), ( - "a", "foo.09", "foo.10", "foo.11", "X.sc", "Y.sc", "Z.sc" - )) + self.assertEqual( + gc.glyphSet(), ("a", "foo.09", "foo.10", "foo.11", "X.sc", "Y.sc", "Z.sc") + ) def test_glyphclass_reference(self): [vowels_lc, vowels_uc, vowels] = self.parse( "@Vowels.lc = [a e i o u]; @Vowels.uc = [A E I O U];" - "@Vowels = [@Vowels.lc @Vowels.uc y Y];").statements + "@Vowels = [@Vowels.lc @Vowels.uc y Y];" + ).statements self.assertEqual(vowels_lc.glyphSet(), tuple("aeiou")) self.assertEqual(vowels_uc.glyphSet(), tuple("AEIOU")) self.assertEqual(vowels.glyphSet(), tuple("aeiouAEIOUyY")) - self.assertEqual(vowels.asFea(), - "@Vowels = [@Vowels.lc @Vowels.uc y Y];") + self.assertEqual(vowels.asFea(), "@Vowels = [@Vowels.lc @Vowels.uc y Y];") self.assertRaisesRegex( - FeatureLibError, "Unknown glyph class @unknown", - self.parse, "@bad = [@unknown];") + FeatureLibError, + "Unknown glyph class @unknown", + self.parse, + "@bad = [@unknown];", + ) def test_glyphclass_scoping(self): [foo, liga, smcp] = self.parse( @@ -447,8 +510,7 @@ def test_glyphclass_scoping_bug496(self): "feature F1 { lookup L { @GLYPHCLASS = [A B C];} L; } F1;" "feature F2 { sub @GLYPHCLASS by D; } F2;" ).statements - self.assertEqual(list(f2.statements[0].glyphs[0].glyphSet()), - ["A", "B", "C"]) + self.assertEqual(list(f2.statements[0].glyphs[0].glyphSet()), ["A", "B", "C"]) def test_GlyphClassDef(self): doc = self.parse("table GDEF {GlyphClassDef [b],[l],[m],[C c];} GDEF;") @@ -481,9 +543,8 @@ def test_ignore_pos(self): def test_ignore_position(self): doc = self.parse( - "feature test {" - " ignore position f [a e] d' [a u]' [e y];" - "} test;") + "feature test {" " ignore position f [a e] d' [a u]' [e y];" "} test;" + ) sub = doc.statements[0].statements[0] self.assertIsInstance(sub, ast.IgnorePosStatement) [(prefix, glyphs, suffix)] = sub.chainContexts @@ -497,7 +558,8 @@ def test_ignore_position_with_lookup(self): 'No lookups can be specified for "ignore pos"', self.parse, "lookup L { pos [A A.sc] -100; } L;" - "feature test { ignore pos f' i', A' lookup L; } test;") + "feature test { ignore pos f' i', A' lookup L; } test;", + ) def test_ignore_sub(self): doc = self.parse("feature test {ignore sub e t' c, q u' u' x;} test;") @@ -513,9 +575,8 @@ def test_ignore_sub(self): def test_ignore_substitute(self): doc = self.parse( - "feature test {" - " ignore substitute f [a e] d' [a u]' [e y];" - "} test;") + "feature test {" " ignore substitute f [a e] d' [a u]' [e y];" "} test;" + ) sub = doc.statements[0].statements[0] self.assertIsInstance(sub, ast.IgnoreSubstStatement) [(prefix, glyphs, suffix)] = sub.chainContexts @@ -529,15 +590,19 @@ def test_ignore_substitute_with_lookup(self): 'No lookups can be specified for "ignore sub"', self.parse, "lookup L { sub [A A.sc] by a; } L;" - "feature test { ignore sub f' i', A' lookup L; } test;") + "feature test { ignore sub f' i', A' lookup L; } test;", + ) def test_include_statement(self): - doc = self.parse("""\ + doc = self.parse( + """\ include(../family.fea); include # Comment (foo) ; - """, followIncludes=False) + """, + followIncludes=False, + ) s1, s2, s3 = doc.statements self.assertEqual(type(s1), ast.IncludeStatement) self.assertEqual(s1.filename, "../family.fea") @@ -549,9 +614,12 @@ def test_include_statement(self): self.assertEqual(s3.text, "# Comment") def test_include_statement_no_semicolon(self): - doc = self.parse("""\ + doc = self.parse( + """\ include(../family.fea) - """, followIncludes=False) + """, + followIncludes=False, + ) s1 = doc.statements[0] self.assertEqual(type(s1), ast.IncludeStatement) self.assertEqual(s1.filename, "../family.fea") @@ -574,9 +642,9 @@ def test_language_exclude_dflt(self): self.assertFalse(s.required) def test_language_exclude_dflt_required(self): - doc = self.parse("feature test {" - " language DEU exclude_dflt required;" - "} test;") + doc = self.parse( + "feature test {" " language DEU exclude_dflt required;" "} test;" + ) s = doc.statements[0].statements[0] self.assertEqual(type(s), ast.LanguageStatement) self.assertEqual(s.language, "DEU ") @@ -592,9 +660,9 @@ def test_language_include_dflt(self): self.assertFalse(s.required) def test_language_include_dflt_required(self): - doc = self.parse("feature test {" - " language DEU include_dflt required;" - "} test;") + doc = self.parse( + "feature test {" " language DEU include_dflt required;" "} test;" + ) s = doc.statements[0].statements[0] self.assertEqual(type(s), ast.LanguageStatement) self.assertEqual(s.language, "DEU ") @@ -605,7 +673,9 @@ def test_language_DFLT(self): self.assertRaisesRegex( FeatureLibError, '"DFLT" is not a valid language tag; use "dflt" instead', - self.parse, "feature test { language DFLT; } test;") + self.parse, + "feature test { language DFLT; } test;", + ) def test_ligatureCaretByIndex_glyphClass(self): doc = self.parse("table GDEF{LigatureCaretByIndex [c_t f_i] 2;}GDEF;") @@ -644,20 +714,21 @@ def test_lookup_block_useExtension(self): [lookup] = self.parse("lookup Foo useExtension {} Foo;").statements self.assertEqual(lookup.name, "Foo") self.assertTrue(lookup.use_extension) - self.assertEqual(lookup.asFea(), - "lookup Foo useExtension {\n \n} Foo;\n") + self.assertEqual(lookup.asFea(), "lookup Foo useExtension {\n \n} Foo;\n") def test_lookup_block_name_mismatch(self): self.assertRaisesRegex( - FeatureLibError, 'Expected "Foo"', - self.parse, "lookup Foo {} Bar;") + FeatureLibError, 'Expected "Foo"', self.parse, "lookup Foo {} Bar;" + ) def test_lookup_block_with_horizontal_valueRecordDef(self): - doc = self.parse("feature liga {" - " lookup look {" - " valueRecordDef 123 foo;" - " } look;" - "} liga;") + doc = self.parse( + "feature liga {" + " lookup look {" + " valueRecordDef 123 foo;" + " } look;" + "} liga;" + ) [liga] = doc.statements [look] = liga.statements [foo] = look.statements @@ -665,11 +736,13 @@ def test_lookup_block_with_horizontal_valueRecordDef(self): self.assertIsNone(foo.value.yAdvance) def test_lookup_block_with_vertical_valueRecordDef(self): - doc = self.parse("feature vkrn {" - " lookup look {" - " valueRecordDef 123 foo;" - " } look;" - "} vkrn;") + doc = self.parse( + "feature vkrn {" + " lookup look {" + " valueRecordDef 123 foo;" + " } look;" + "} vkrn;" + ) [vkrn] = doc.statements [look] = vkrn.statements [foo] = look.statements @@ -683,15 +756,17 @@ def test_lookup_comment(self): self.assertEqual(comment.text, "# Comment") def test_lookup_reference(self): - [foo, bar] = self.parse("lookup Foo {} Foo;" - "feature Bar {lookup Foo;} Bar;").statements + [foo, bar] = self.parse( + "lookup Foo {} Foo;" "feature Bar {lookup Foo;} Bar;" + ).statements [ref] = bar.statements self.assertEqual(type(ref), ast.LookupReferenceStatement) self.assertEqual(ref.lookup, foo) def test_lookup_reference_to_lookup_inside_feature(self): - [qux, bar] = self.parse("feature Qux {lookup Foo {} Foo;} Qux;" - "feature Bar {lookup Foo;} Bar;").statements + [qux, bar] = self.parse( + "feature Qux {lookup Foo {} Foo;} Qux;" "feature Bar {lookup Foo;} Bar;" + ).statements [foo] = qux.statements [ref] = bar.statements self.assertIsInstance(ref, ast.LookupReferenceStatement) @@ -699,8 +774,11 @@ def test_lookup_reference_to_lookup_inside_feature(self): def test_lookup_reference_unknown(self): self.assertRaisesRegex( - FeatureLibError, 'Unknown lookup "Huh"', - self.parse, "feature liga {lookup Huh;} liga;") + FeatureLibError, + 'Unknown lookup "Huh"', + self.parse, + "feature liga {lookup Huh;} liga;", + ) def parse_lookupflag_(self, s): return self.parse("lookup L {%s} L;" % s).statements[0].statements[-1] @@ -716,52 +794,59 @@ def test_lookupflag_format_A(self): def test_lookupflag_format_A_MarkAttachmentType(self): flag = self.parse_lookupflag_( "@TOP_MARKS = [acute grave macron];" - "lookupflag RightToLeft MarkAttachmentType @TOP_MARKS;") + "lookupflag RightToLeft MarkAttachmentType @TOP_MARKS;" + ) self.assertIsInstance(flag, ast.LookupFlagStatement) self.assertEqual(flag.value, 1) self.assertIsInstance(flag.markAttachment, ast.GlyphClassName) - self.assertEqual(flag.markAttachment.glyphSet(), - ("acute", "grave", "macron")) + self.assertEqual(flag.markAttachment.glyphSet(), ("acute", "grave", "macron")) self.assertIsNone(flag.markFilteringSet) - self.assertEqual(flag.asFea(), - "lookupflag RightToLeft MarkAttachmentType @TOP_MARKS;") + self.assertEqual( + flag.asFea(), "lookupflag RightToLeft MarkAttachmentType @TOP_MARKS;" + ) def test_lookupflag_format_A_MarkAttachmentType_glyphClass(self): flag = self.parse_lookupflag_( - "lookupflag RightToLeft MarkAttachmentType [acute grave macron];") + "lookupflag RightToLeft MarkAttachmentType [acute grave macron];" + ) self.assertIsInstance(flag, ast.LookupFlagStatement) self.assertEqual(flag.value, 1) self.assertIsInstance(flag.markAttachment, ast.GlyphClass) - self.assertEqual(flag.markAttachment.glyphSet(), - ("acute", "grave", "macron")) + self.assertEqual(flag.markAttachment.glyphSet(), ("acute", "grave", "macron")) self.assertIsNone(flag.markFilteringSet) - self.assertEqual(flag.asFea(), - "lookupflag RightToLeft MarkAttachmentType [acute grave macron];") + self.assertEqual( + flag.asFea(), + "lookupflag RightToLeft MarkAttachmentType [acute grave macron];", + ) def test_lookupflag_format_A_UseMarkFilteringSet(self): flag = self.parse_lookupflag_( "@BOTTOM_MARKS = [cedilla ogonek];" - "lookupflag UseMarkFilteringSet @BOTTOM_MARKS IgnoreLigatures;") + "lookupflag UseMarkFilteringSet @BOTTOM_MARKS IgnoreLigatures;" + ) self.assertIsInstance(flag, ast.LookupFlagStatement) self.assertEqual(flag.value, 4) self.assertIsNone(flag.markAttachment) self.assertIsInstance(flag.markFilteringSet, ast.GlyphClassName) - self.assertEqual(flag.markFilteringSet.glyphSet(), - ("cedilla", "ogonek")) - self.assertEqual(flag.asFea(), - "lookupflag IgnoreLigatures UseMarkFilteringSet @BOTTOM_MARKS;") + self.assertEqual(flag.markFilteringSet.glyphSet(), ("cedilla", "ogonek")) + self.assertEqual( + flag.asFea(), + "lookupflag IgnoreLigatures UseMarkFilteringSet @BOTTOM_MARKS;", + ) def test_lookupflag_format_A_UseMarkFilteringSet_glyphClass(self): flag = self.parse_lookupflag_( - "lookupflag UseMarkFilteringSet [cedilla ogonek] IgnoreLigatures;") + "lookupflag UseMarkFilteringSet [cedilla ogonek] IgnoreLigatures;" + ) self.assertIsInstance(flag, ast.LookupFlagStatement) self.assertEqual(flag.value, 4) self.assertIsNone(flag.markAttachment) self.assertIsInstance(flag.markFilteringSet, ast.GlyphClass) - self.assertEqual(flag.markFilteringSet.glyphSet(), - ("cedilla", "ogonek")) - self.assertEqual(flag.asFea(), - "lookupflag IgnoreLigatures UseMarkFilteringSet [cedilla ogonek];") + self.assertEqual(flag.markFilteringSet.glyphSet(), ("cedilla", "ogonek")) + self.assertEqual( + flag.asFea(), + "lookupflag IgnoreLigatures UseMarkFilteringSet [cedilla ogonek];", + ) def test_lookupflag_format_B(self): flag = self.parse_lookupflag_("lookupflag 7;") @@ -769,8 +854,9 @@ def test_lookupflag_format_B(self): self.assertEqual(flag.value, 7) self.assertIsNone(flag.markAttachment) self.assertIsNone(flag.markFilteringSet) - self.assertEqual(flag.asFea(), - "lookupflag RightToLeft IgnoreBaseGlyphs IgnoreLigatures;") + self.assertEqual( + flag.asFea(), "lookupflag RightToLeft IgnoreBaseGlyphs IgnoreLigatures;" + ) def test_lookupflag_format_B_zero(self): flag = self.parse_lookupflag_("lookupflag 0;") @@ -783,22 +869,26 @@ def test_lookupflag_format_B_zero(self): def test_lookupflag_no_value(self): self.assertRaisesRegex( FeatureLibError, - 'lookupflag must have a value', + "lookupflag must have a value", self.parse, - "feature test {lookupflag;} test;") + "feature test {lookupflag;} test;", + ) def test_lookupflag_repeated(self): self.assertRaisesRegex( FeatureLibError, - 'RightToLeft can be specified only once', + "RightToLeft can be specified only once", self.parse, - "feature test {lookupflag RightToLeft RightToLeft;} test;") + "feature test {lookupflag RightToLeft RightToLeft;} test;", + ) def test_lookupflag_unrecognized(self): self.assertRaisesRegex( FeatureLibError, '"IgnoreCookies" is not a recognized lookupflag', - self.parse, "feature test {lookupflag IgnoreCookies;} test;") + self.parse, + "feature test {lookupflag IgnoreCookies;} test;", + ) def test_gpos_type_1_glyph(self): doc = self.parse("feature kern {pos one <1 2 3 4>;} kern;") @@ -842,11 +932,15 @@ def test_gpos_type_1_enumerated(self): self.assertRaisesRegex( FeatureLibError, '"enumerate" is only allowed with pair positionings', - self.parse, "feature test {enum pos T 100;} test;") + self.parse, + "feature test {enum pos T 100;} test;", + ) self.assertRaisesRegex( FeatureLibError, '"enumerate" is only allowed with pair positionings', - self.parse, "feature test {enumerate pos T 100;} test;") + self.parse, + "feature test {enumerate pos T 100;} test;", + ) def test_gpos_type_1_chained(self): doc = self.parse("feature kern {pos [A B] [T Y]' 20 comma;} kern;") @@ -891,32 +985,34 @@ def test_gpos_type_1_chained_special_kern_format_valuerecord_format_b_bug2293(se 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;") + 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;") + 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;") + 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;") + 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>;" - "} kern;") + doc = self.parse( + "feature kern {" " pos [T V] -60 [a b c] <1 2 3 4>;" "} kern;" + ) pos = doc.statements[0].statements[0] self.assertEqual(type(pos), ast.PairPosStatement) self.assertFalse(pos.enumerated) @@ -926,9 +1022,9 @@ def test_gpos_type_2_format_a(self): self.assertEqual(pos.valuerecord2.asFea(), "<1 2 3 4>") def test_gpos_type_2_format_a_enumerated(self): - doc = self.parse("feature kern {" - " enum pos [T V] -60 [a b c] <1 2 3 4>;" - "} kern;") + doc = self.parse( + "feature kern {" " enum pos [T V] -60 [a b c] <1 2 3 4>;" "} kern;" + ) pos = doc.statements[0].statements[0] self.assertEqual(type(pos), ast.PairPosStatement) self.assertTrue(pos.enumerated) @@ -938,9 +1034,9 @@ def test_gpos_type_2_format_a_enumerated(self): self.assertEqual(pos.valuerecord2.asFea(), "<1 2 3 4>") def test_gpos_type_2_format_a_with_null_first(self): - doc = self.parse("feature kern {" - " pos [T V] [a b c] <1 2 3 4>;" - "} kern;") + doc = self.parse( + "feature kern {" " pos [T V] [a b c] <1 2 3 4>;" "} kern;" + ) pos = doc.statements[0].statements[0] self.assertEqual(type(pos), ast.PairPosStatement) self.assertFalse(pos.enumerated) @@ -952,9 +1048,9 @@ def test_gpos_type_2_format_a_with_null_first(self): self.assertEqual(pos.asFea(), "pos [T V] [a b c] <1 2 3 4>;") def test_gpos_type_2_format_a_with_null_second(self): - doc = self.parse("feature kern {" - " pos [T V] <1 2 3 4> [a b c] ;" - "} kern;") + doc = self.parse( + "feature kern {" " pos [T V] <1 2 3 4> [a b c] ;" "} kern;" + ) pos = doc.statements[0].statements[0] self.assertEqual(type(pos), ast.PairPosStatement) self.assertFalse(pos.enumerated) @@ -965,9 +1061,7 @@ def test_gpos_type_2_format_a_with_null_second(self): self.assertEqual(pos.asFea(), "pos [T V] [a b c] <1 2 3 4>;") def test_gpos_type_2_format_b(self): - doc = self.parse("feature kern {" - " pos [T V] [a b c] <1 2 3 4>;" - "} kern;") + doc = self.parse("feature kern {" " pos [T V] [a b c] <1 2 3 4>;" "} kern;") pos = doc.statements[0].statements[0] self.assertEqual(type(pos), ast.PairPosStatement) self.assertFalse(pos.enumerated) @@ -977,9 +1071,9 @@ def test_gpos_type_2_format_b(self): self.assertIsNone(pos.valuerecord2) def test_gpos_type_2_format_b_enumerated(self): - doc = self.parse("feature kern {" - " enumerate position [T V] [a b c] <1 2 3 4>;" - "} kern;") + doc = self.parse( + "feature kern {" " enumerate position [T V] [a b c] <1 2 3 4>;" "} kern;" + ) pos = doc.statements[0].statements[0] self.assertEqual(type(pos), ast.PairPosStatement) self.assertTrue(pos.enumerated) @@ -989,9 +1083,11 @@ def test_gpos_type_2_format_b_enumerated(self): self.assertIsNone(pos.valuerecord2) def test_gpos_type_3(self): - doc = self.parse("feature kern {" - " position cursive A ;" - "} kern;") + doc = self.parse( + "feature kern {" + " position cursive A ;" + "} kern;" + ) pos = doc.statements[0].statements[0] self.assertEqual(type(pos), ast.CursivePosStatement) self.assertEqual(pos.glyphclass.glyphSet(), ("A",)) @@ -1005,7 +1101,8 @@ def test_gpos_type_3_enumerated(self): self.parse, "feature kern {" " enumerate position cursive A ;" - "} kern;") + "} kern;", + ) def test_gpos_type_4(self): doc = self.parse( @@ -1016,7 +1113,8 @@ def test_gpos_type_4(self): " position base [a e o u] " " mark @TOP_MARKS " " mark @BOTTOM_MARKS;" - "} test;") + "} test;" + ) pos = doc.statements[-1].statements[0] self.assertEqual(type(pos), ast.MarkBasePosStatement) self.assertEqual(pos.base.glyphSet(), ("a", "e", "o", "u")) @@ -1027,21 +1125,24 @@ def test_gpos_type_4(self): def test_gpos_type_4_enumerated(self): self.assertRaisesRegex( FeatureLibError, - '"enumerate" is not allowed with ' - 'mark-to-base attachment positioning', + '"enumerate" is not allowed with ' "mark-to-base attachment positioning", self.parse, "feature kern {" " markClass cedilla @BOTTOM_MARKS;" " enumerate position base A mark @BOTTOM_MARKS;" - "} kern;") + "} kern;", + ) def test_gpos_type_4_not_markClass(self): self.assertRaisesRegex( - FeatureLibError, "@MARKS is not a markClass", self.parse, + FeatureLibError, + "@MARKS is not a markClass", + self.parse, "@MARKS = [acute grave];" "feature test {" " position base [a e o u] mark @MARKS;" - "} test;") + "} test;", + ) def test_gpos_type_5(self): doc = self.parse( @@ -1058,7 +1159,8 @@ def test_gpos_type_5(self): " " " ligComponent " " mark @BOTTOM_MARKS;" - "} test;") + "} test;" + ) pos = doc.statements[-1].statements[0] self.assertEqual(type(pos), ast.MarkLigPosStatement) self.assertEqual(pos.ligatures.glyphSet(), ("a_f_f_i", "o_f_f_i")) @@ -1072,29 +1174,34 @@ def test_gpos_type_5_enumerated(self): self.assertRaisesRegex( FeatureLibError, '"enumerate" is not allowed with ' - 'mark-to-ligature attachment positioning', + "mark-to-ligature attachment positioning", self.parse, "feature test {" " markClass cedilla @MARKS;" " enumerate position " " ligature f_i mark @MARKS" " ligComponent ;" - "} test;") + "} test;", + ) def test_gpos_type_5_not_markClass(self): self.assertRaisesRegex( - FeatureLibError, "@MARKS is not a markClass", self.parse, + FeatureLibError, + "@MARKS is not a markClass", + self.parse, "@MARKS = [acute grave];" "feature test {" " position ligature f_i mark @MARKS;" - "} test;") + "} test;", + ) def test_gpos_type_6(self): doc = self.parse( "markClass damma @MARK_CLASS_1;" "feature test {" " position mark hamza mark @MARK_CLASS_1;" - "} test;") + "} test;" + ) pos = doc.statements[-1].statements[0] self.assertEqual(type(pos), ast.MarkMarkPosStatement) self.assertEqual(pos.baseMarks.glyphSet(), ("hamza",)) @@ -1104,28 +1211,32 @@ def test_gpos_type_6(self): def test_gpos_type_6_enumerated(self): self.assertRaisesRegex( FeatureLibError, - '"enumerate" is not allowed with ' - 'mark-to-mark attachment positioning', + '"enumerate" is not allowed with ' "mark-to-mark attachment positioning", self.parse, "markClass damma @MARK_CLASS_1;" "feature test {" " enum pos mark hamza mark @MARK_CLASS_1;" - "} test;") + "} test;", + ) def test_gpos_type_6_not_markClass(self): self.assertRaisesRegex( - FeatureLibError, "@MARKS is not a markClass", self.parse, + FeatureLibError, + "@MARKS is not a markClass", + self.parse, "@MARKS = [acute grave];" "feature test {" " position mark cedilla mark @MARKS;" - "} test;") + "} test;", + ) def test_gpos_type_8(self): doc = self.parse( "lookup L1 {pos one 100;} L1; lookup L2 {pos two 200;} L2;" "feature test {" " pos [A a] [B b] I' lookup L1 [N n]' lookup L2 P' [Y y] [Z z];" - "} test;") + "} test;" + ) lookup1, lookup2 = doc.statements[0:2] pos = doc.statements[-1].statements[0] self.assertEqual(type(pos), ast.ChainContextPosStatement) @@ -1142,7 +1253,8 @@ def test_gpos_type_8_lookup_with_values(self): "lookup L1 {pos one 100;} L1;" "feature test {" " pos A' lookup L1 B' 20;" - "} test;") + "} test;", + ) def test_markClass(self): doc = self.parse("markClass [acute grave] @MARKS;") @@ -1153,8 +1265,7 @@ def test_markClass(self): self.assertEqual((mc.anchor.x, mc.anchor.y), (350, 3)) def test_nameid_windows_utf16(self): - doc = self.parse( - r'table name { nameid 9 "M\00fcller-Lanc\00e9"; } name;') + doc = self.parse(r'table name { nameid 9 "M\00fcller-Lanc\00e9"; } name;') name = doc.statements[0].statements[0] self.assertIsInstance(name, ast.NameRecord) self.assertEqual(name.nameID, 9) @@ -1171,8 +1282,7 @@ def test_nameid_windows_utf16_backslash(self): self.assertEqual(name.asFea(), r'nameid 9 "Back\005cslash";') def test_nameid_windows_utf16_quotation_mark(self): - doc = self.parse( - r'table name { nameid 9 "Quotation \0022Mark\0022"; } name;') + doc = self.parse(r'table name { nameid 9 "Quotation \0022Mark\0022"; } name;') name = doc.statements[0].statements[0] self.assertEqual(name.string, 'Quotation "Mark"') self.assertEqual(name.asFea(), r'nameid 9 "Quotation \0022Mark\0022";') @@ -1184,8 +1294,7 @@ def test_nameid_windows_utf16_surroates(self): self.assertEqual(name.asFea(), r'nameid 9 "Carrot \d83e\dd55";') def test_nameid_mac_roman(self): - doc = self.parse( - r'table name { nameid 9 1 "Joachim M\9fller-Lanc\8e"; } name;') + doc = self.parse(r'table name { nameid 9 1 "Joachim M\9fller-Lanc\8e"; } name;') name = doc.statements[0].statements[0] self.assertIsInstance(name, ast.NameRecord) self.assertEqual(name.nameID, 9) @@ -1193,12 +1302,10 @@ def test_nameid_mac_roman(self): self.assertEqual(name.platEncID, 0) self.assertEqual(name.langID, 0) self.assertEqual(name.string, "Joachim Müller-Lancé") - self.assertEqual(name.asFea(), - r'nameid 9 1 "Joachim M\9fller-Lanc\8e";') + self.assertEqual(name.asFea(), r'nameid 9 1 "Joachim M\9fller-Lanc\8e";') def test_nameid_mac_croatian(self): - doc = self.parse( - r'table name { nameid 9 1 0 18 "Jovica Veljovi\e6"; } name;') + doc = self.parse(r'table name { nameid 9 1 0 18 "Jovica Veljovi\e6"; } name;') name = doc.statements[0].statements[0] self.assertEqual(name.nameID, 9) self.assertEqual(name.platformID, 1) @@ -1209,12 +1316,14 @@ def test_nameid_mac_croatian(self): def test_nameid_unsupported_platform(self): self.assertRaisesRegex( - FeatureLibError, "Expected platform id 1 or 3", - self.parse, 'table name { nameid 9 666 "Foo"; } name;') + FeatureLibError, + "Expected platform id 1 or 3", + self.parse, + 'table name { nameid 9 666 "Foo"; } name;', + ) def test_nameid_hexadecimal(self): - doc = self.parse( - r'table name { nameid 0x9 0x3 0x1 0x0409 "Test"; } name;') + doc = self.parse(r'table name { nameid 0x9 0x3 0x1 0x0409 "Test"; } name;') name = doc.statements[0].statements[0] self.assertEqual(name.nameID, 9) self.assertEqual(name.platformID, 3) @@ -1222,8 +1331,7 @@ def test_nameid_hexadecimal(self): self.assertEqual(name.langID, 0x0409) def test_nameid_octal(self): - doc = self.parse( - r'table name { nameid 011 03 012 02011 "Test"; } name;') + doc = self.parse(r'table name { nameid 011 03 012 02011 "Test"; } name;') name = doc.statements[0].statements[0] self.assertEqual(name.nameID, 9) self.assertEqual(name.platformID, 3) @@ -1231,14 +1339,12 @@ def test_nameid_octal(self): self.assertEqual(name.langID, 0o2011) def test_cv_hexadecimal(self): - doc = self.parse( - r'feature cv01 { cvParameters { Character 0x5DDE; }; } cv01;') + doc = self.parse(r"feature cv01 { cvParameters { Character 0x5DDE; }; } cv01;") cv = doc.statements[0].statements[0].statements[0] self.assertEqual(cv.character, 0x5DDE) def test_cv_octal(self): - doc = self.parse( - r'feature cv01 { cvParameters { Character 056736; }; } cv01;') + doc = self.parse(r"feature cv01 { cvParameters { Character 056736; }; } cv01;") cv = doc.statements[0].statements[0].statements[0] self.assertEqual(cv.character, 0o56736) @@ -1255,8 +1361,7 @@ def test_rsub_format_a_cid(self): doc = self.parse(r"feature test {rsub \1 [\2 \3] \4' \5 by \6;} test;") rsub = doc.statements[0].statements[0] self.assertEqual(type(rsub), ast.ReverseChainSingleSubstStatement) - self.assertEqual(glyphstr(rsub.old_prefix), - "cid00001 [cid00002 cid00003]") + self.assertEqual(glyphstr(rsub.old_prefix), "cid00001 [cid00002 cid00003]") self.assertEqual(rsub.glyphs[0].glyphSet(), ("cid00004",)) self.assertEqual(rsub.replacements[0].glyphSet(), ("cid00006",)) self.assertEqual(glyphstr(rsub.old_suffix), "cid00005") @@ -1265,51 +1370,53 @@ def test_rsub_format_b(self): doc = self.parse( "feature smcp {" " reversesub A B [one.fitted one.oldstyle]' C [d D] by one;" - "} smcp;") + "} smcp;" + ) rsub = doc.statements[0].statements[0] self.assertEqual(type(rsub), ast.ReverseChainSingleSubstStatement) self.assertEqual(glyphstr(rsub.old_prefix), "A B") self.assertEqual(glyphstr(rsub.old_suffix), "C [D d]") - self.assertEqual(mapping(rsub), { - "one.fitted": "one", - "one.oldstyle": "one" - }) + self.assertEqual(mapping(rsub), {"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;") + "} test;" + ) rsub = doc.statements[0].statements[0] self.assertEqual(type(rsub), ast.ReverseChainSingleSubstStatement) self.assertEqual(glyphstr(rsub.old_prefix), "BACK TRACK") self.assertEqual(glyphstr(rsub.old_suffix), "LOOK AHEAD") - self.assertEqual(mapping(rsub), { - "a": "A.sc", - "b": "B.sc", - "c": "C.sc", - "d": "D.sc" - }) + self.assertEqual( + mapping(rsub), {"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;") + 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;") + 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 ' + "In reverse chaining single substitutions, the replacement " r'\(after "by"\) must be a single glyph or glyph class', - self.parse, "feature test {rsub f_i by f i;} test;") + self.parse, + "feature test {rsub f_i by f i;} test;", + ) def test_script(self): doc = self.parse("feature test {script cyrl;} test;") @@ -1321,77 +1428,92 @@ def test_script_dflt(self): self.assertRaisesRegex( FeatureLibError, '"dflt" is not a valid script tag; use "DFLT" instead', - self.parse, "feature test {script dflt;} test;") + self.parse, + "feature test {script dflt;} test;", + ) def test_stat_design_axis(self): # STAT DesignAxis - doc = self.parse('table STAT { DesignAxis opsz 0 ' - '{name "Optical Size";}; } STAT;') + doc = self.parse( + "table STAT { DesignAxis opsz 0 " '{name "Optical Size";}; } STAT;' + ) da = doc.statements[0].statements[0] self.assertIsInstance(da, ast.STATDesignAxisStatement) - self.assertEqual(da.tag, 'opsz') + self.assertEqual(da.tag, "opsz") self.assertEqual(da.axisOrder, 0) - self.assertEqual(da.names[0].string, 'Optical Size') + self.assertEqual(da.names[0].string, "Optical Size") def test_stat_axis_value_format1(self): # STAT AxisValue - doc = self.parse('table STAT { DesignAxis opsz 0 ' - '{name "Optical Size";}; ' - 'AxisValue {location opsz 8; name "Caption";}; } ' - 'STAT;') + doc = self.parse( + "table STAT { DesignAxis opsz 0 " + '{name "Optical Size";}; ' + 'AxisValue {location opsz 8; name "Caption";}; } ' + "STAT;" + ) avr = doc.statements[0].statements[1] self.assertIsInstance(avr, ast.STATAxisValueStatement) - self.assertEqual(avr.locations[0].tag, 'opsz') + self.assertEqual(avr.locations[0].tag, "opsz") self.assertEqual(avr.locations[0].values[0], 8) - self.assertEqual(avr.names[0].string, 'Caption') + self.assertEqual(avr.names[0].string, "Caption") def test_stat_axis_value_format2(self): # STAT AxisValue - doc = self.parse('table STAT { DesignAxis opsz 0 ' - '{name "Optical Size";}; ' - 'AxisValue {location opsz 8 6 10; name "Caption";}; } ' - 'STAT;') + doc = self.parse( + "table STAT { DesignAxis opsz 0 " + '{name "Optical Size";}; ' + 'AxisValue {location opsz 8 6 10; name "Caption";}; } ' + "STAT;" + ) avr = doc.statements[0].statements[1] self.assertIsInstance(avr, ast.STATAxisValueStatement) - self.assertEqual(avr.locations[0].tag, 'opsz') + self.assertEqual(avr.locations[0].tag, "opsz") self.assertEqual(avr.locations[0].values, [8, 6, 10]) - self.assertEqual(avr.names[0].string, 'Caption') + self.assertEqual(avr.names[0].string, "Caption") def test_stat_axis_value_format2_bad_range(self): # STAT AxisValue self.assertRaisesRegex( FeatureLibError, - 'Default value 5 is outside of specified range 6-10.', - self.parse, 'table STAT { DesignAxis opsz 0 ' - '{name "Optical Size";}; ' - 'AxisValue {location opsz 5 6 10; name "Caption";}; } ' - 'STAT;') + "Default value 5 is outside of specified range 6-10.", + self.parse, + "table STAT { DesignAxis opsz 0 " + '{name "Optical Size";}; ' + 'AxisValue {location opsz 5 6 10; name "Caption";}; } ' + "STAT;", + ) def test_stat_axis_value_format4(self): # STAT AxisValue self.assertRaisesRegex( FeatureLibError, - 'Only one value is allowed in a Format 4 Axis Value Record, but 3 were found.', - self.parse, 'table STAT { ' - 'DesignAxis opsz 0 {name "Optical Size";}; ' - 'DesignAxis wdth 0 {name "Width";}; ' - 'AxisValue {' - 'location opsz 8 6 10; ' - 'location wdth 400; ' - 'name "Caption";}; } ' - 'STAT;') + "Only one value is allowed in a Format 4 Axis Value Record, but 3 were found.", + self.parse, + "table STAT { " + 'DesignAxis opsz 0 {name "Optical Size";}; ' + 'DesignAxis wdth 0 {name "Width";}; ' + "AxisValue {" + "location opsz 8 6 10; " + "location wdth 400; " + 'name "Caption";}; } ' + "STAT;", + ) def test_stat_elidedfallbackname(self): # STAT ElidedFallbackName - doc = self.parse('table STAT { ElidedFallbackName {name "Roman"; ' - 'name 3 1 0x0411 "ローマン"; }; ' - '} STAT;') + doc = self.parse( + 'table STAT { ElidedFallbackName {name "Roman"; ' + 'name 3 1 0x0411 "ローマン"; }; ' + "} STAT;" + ) nameRecord = doc.statements[0].statements[0] self.assertIsInstance(nameRecord, ast.ElidedFallbackName) - self.assertEqual(nameRecord.names[0].string, 'Roman') - self.assertEqual(nameRecord.names[1].string, 'ローマン') + self.assertEqual(nameRecord.names[0].string, "Roman") + self.assertEqual(nameRecord.names[1].string, "ローマン") def test_stat_elidedfallbacknameid(self): # STAT ElidedFallbackNameID - doc = self.parse('table name { nameid 278 "Roman"; } name; ' - 'table STAT { ElidedFallbackNameID 278; ' - '} STAT;') + doc = self.parse( + 'table name { nameid 278 "Roman"; } name; ' + "table STAT { ElidedFallbackNameID 278; " + "} STAT;" + ) nameRecord = doc.statements[0].statements[0] self.assertIsInstance(nameRecord, ast.NameRecord) - self.assertEqual(nameRecord.string, 'Roman') + self.assertEqual(nameRecord.string, "Roman") def test_sub_single_format_a(self): # GSUB LookupType 1 doc = self.parse("feature smcp {substitute a by a.sc;} smcp;") @@ -1421,13 +1543,11 @@ def test_sub_single_format_b(self): # GSUB LookupType 1 doc = self.parse( "feature smcp {" " substitute [one.fitted one.oldstyle] by one;" - "} smcp;") + "} smcp;" + ) sub = doc.statements[0].statements[0] self.assertIsInstance(sub, ast.SingleSubstStatement) - self.assertEqual(mapping(sub), { - "one.fitted": "one", - "one.oldstyle": "one" - }) + self.assertEqual(mapping(sub), {"one.fitted": "one", "one.oldstyle": "one"}) self.assertEqual(glyphstr(sub.prefix), "") self.assertEqual(glyphstr(sub.suffix), "") @@ -1435,45 +1555,35 @@ def test_sub_single_format_b_chained(self): # chain to GSUB LookupType 1 doc = self.parse( "feature smcp {" " substitute PRE FIX [one.fitted one.oldstyle]' SUF FIX by one;" - "} smcp;") + "} smcp;" + ) sub = doc.statements[0].statements[0] self.assertIsInstance(sub, ast.SingleSubstStatement) - self.assertEqual(mapping(sub), { - "one.fitted": "one", - "one.oldstyle": "one" - }) + self.assertEqual(mapping(sub), {"one.fitted": "one", "one.oldstyle": "one"}) self.assertEqual(glyphstr(sub.prefix), "PRE FIX") self.assertEqual(glyphstr(sub.suffix), "SUF FIX") def test_sub_single_format_c(self): # GSUB LookupType 1 doc = self.parse( - "feature smcp {" - " substitute [a-d] by [A.sc-D.sc];" - "} smcp;") + "feature smcp {" " substitute [a-d] by [A.sc-D.sc];" "} smcp;" + ) sub = doc.statements[0].statements[0] self.assertIsInstance(sub, ast.SingleSubstStatement) - self.assertEqual(mapping(sub), { - "a": "A.sc", - "b": "B.sc", - "c": "C.sc", - "d": "D.sc" - }) + self.assertEqual( + mapping(sub), {"a": "A.sc", "b": "B.sc", "c": "C.sc", "d": "D.sc"} + ) self.assertEqual(glyphstr(sub.prefix), "") self.assertEqual(glyphstr(sub.suffix), "") def test_sub_single_format_c_chained(self): # chain to GSUB LookupType 1 doc = self.parse( - "feature smcp {" - " substitute [a-d]' X Y [Z z] by [A.sc-D.sc];" - "} smcp;") + "feature smcp {" " substitute [a-d]' X Y [Z z] by [A.sc-D.sc];" "} smcp;" + ) sub = doc.statements[0].statements[0] self.assertIsInstance(sub, ast.SingleSubstStatement) - self.assertEqual(mapping(sub), { - "a": "A.sc", - "b": "B.sc", - "c": "C.sc", - "d": "D.sc" - }) + self.assertEqual( + mapping(sub), {"a": "A.sc", "b": "B.sc", "c": "C.sc", "d": "D.sc"} + ) self.assertEqual(glyphstr(sub.prefix), "") self.assertEqual(glyphstr(sub.suffix), "X Y [Z z]") @@ -1481,14 +1591,18 @@ def test_sub_single_format_c_different_num_elements(self): self.assertRaisesRegex( FeatureLibError, 'Expected a glyph class with 4 elements after "by", ' - 'but found a glyph class with 26 elements', - self.parse, "feature smcp {sub [a-d] by [A.sc-Z.sc];} smcp;") + "but found a glyph class with 26 elements", + self.parse, + "feature smcp {sub [a-d] by [A.sc-Z.sc];} smcp;", + ) def test_sub_with_values(self): self.assertRaisesRegex( FeatureLibError, "Substitution statements cannot contain values", - self.parse, "feature smcp {sub A' 20 by A.sc;} smcp;") + self.parse, + "feature smcp {sub A' 20 by A.sc;} smcp;", + ) def test_substitute_multiple(self): # GSUB LookupType 2 doc = self.parse("lookup Look {substitute f_f_i by f f i;} Look;") @@ -1518,52 +1632,59 @@ def test_substitute_multiple_by_mutliple(self): "Direct substitution of multiple glyphs by multiple glyphs " "is not supported", self.parse, - "lookup MxM {sub a b c by d e f;} MxM;") + "lookup MxM {sub a b c by d e f;} MxM;", + ) def test_split_marked_glyphs_runs(self): self.assertRaisesRegex( FeatureLibError, "Unsupported contextual target sequence", - self.parse, "feature test{" - " ignore pos a' x x A';" - "} test;") + self.parse, + "feature test{" " ignore pos a' x x A';" "} test;", + ) self.assertRaisesRegex( FeatureLibError, "Unsupported contextual target sequence", - self.parse, "lookup shift {" - " pos a <0 -10 0 0>;" - " pos A <0 10 0 0>;" - "} shift;" - "feature test {" - " sub a' lookup shift x x A' lookup shift;" - "} test;") + self.parse, + "lookup shift {" + " pos a <0 -10 0 0>;" + " pos A <0 10 0 0>;" + "} shift;" + "feature test {" + " sub a' lookup shift x x A' lookup shift;" + "} test;", + ) self.assertRaisesRegex( FeatureLibError, "Unsupported contextual target sequence", - self.parse, "feature test {" - " ignore sub a' x x A';" - "} test;") + self.parse, + "feature test {" " ignore sub a' x x A';" "} test;", + ) self.assertRaisesRegex( FeatureLibError, "Unsupported contextual target sequence", - self.parse, "lookup upper {" - " sub a by A;" - "} upper;" - "lookup lower {" - " sub A by a;" - "} lower;" - "feature test {" - " sub a' lookup upper x x A' lookup lower;" - "} test;") + self.parse, + "lookup upper {" + " sub a by A;" + "} upper;" + "lookup lower {" + " sub A by a;" + "} lower;" + "feature test {" + " sub a' lookup upper x x A' lookup lower;" + "} test;", + ) def test_substitute_mix_single_multiple(self): - doc = self.parse("lookup Look {" - " sub f_f by f f;" - " sub f by f;" - " sub f_f_i by f f i;" - " sub [a a.sc] by a;" - " sub [a a.sc] by [b b.sc];" - "} Look;") + doc = self.parse( + "lookup Look {" + " sub f_f by f f;" + " sub f by f;" + " sub f_f_i by f f i;" + " sub [a a.sc] by a;" + " sub [a a.sc] by [b b.sc];" + "} Look;" + ) statements = doc.statements[0].statements for sub in statements: self.assertIsInstance(sub, ast.MultipleSubstStatement) @@ -1579,9 +1700,9 @@ def test_substitute_mix_single_multiple(self): self.assertEqual(statements[6].replacement, ["b.sc"]) def test_substitute_from(self): # GSUB LookupType 3 - doc = self.parse("feature test {" - " substitute a from [a.1 a.2 a.3];" - "} test;") + doc = self.parse( + "feature test {" " substitute a from [a.1 a.2 a.3];" "} test;" + ) sub = doc.statements[0].statements[0] self.assertIsInstance(sub, ast.AlternateSubstStatement) self.assertEqual(glyphstr(sub.prefix), "") @@ -1590,9 +1711,9 @@ def test_substitute_from(self): # GSUB LookupType 3 self.assertEqual(glyphstr([sub.replacement]), "[a.1 a.2 a.3]") def test_substitute_from_chained(self): # chain to GSUB LookupType 3 - doc = self.parse("feature test {" - " substitute A B a' [Y y] Z from [a.1 a.2 a.3];" - "} test;") + doc = self.parse( + "feature test {" " substitute A B a' [Y y] Z from [a.1 a.2 a.3];" "} test;" + ) sub = doc.statements[0].statements[0] self.assertIsInstance(sub, ast.AlternateSubstStatement) self.assertEqual(glyphstr(sub.prefix), "A B") @@ -1601,9 +1722,9 @@ def test_substitute_from_chained(self): # chain to GSUB LookupType 3 self.assertEqual(glyphstr([sub.replacement]), "[a.1 a.2 a.3]") def test_substitute_from_cid(self): # GSUB LookupType 3 - doc = self.parse(r"feature test {" - r" substitute \7 from [\111 \222];" - r"} test;") + doc = self.parse( + r"feature test {" r" substitute \7 from [\111 \222];" r"} test;" + ) sub = doc.statements[0].statements[0] self.assertIsInstance(sub, ast.AlternateSubstStatement) self.assertEqual(glyphstr(sub.prefix), "") @@ -1612,17 +1733,18 @@ def test_substitute_from_cid(self): # GSUB LookupType 3 self.assertEqual(glyphstr([sub.replacement]), "[cid00111 cid00222]") def test_substitute_from_glyphclass(self): # GSUB LookupType 3 - doc = self.parse("feature test {" - " @Ampersands = [ampersand.1 ampersand.2];" - " substitute ampersand from @Ampersands;" - "} test;") + doc = self.parse( + "feature test {" + " @Ampersands = [ampersand.1 ampersand.2];" + " substitute ampersand from @Ampersands;" + "} test;" + ) [glyphclass, sub] = doc.statements[0].statements self.assertIsInstance(sub, ast.AlternateSubstStatement) self.assertEqual(glyphstr(sub.prefix), "") self.assertEqual(glyphstr([sub.glyph]), "ampersand") self.assertEqual(glyphstr(sub.suffix), "") - self.assertEqual(glyphstr([sub.replacement]), - "[ampersand.1 ampersand.2]") + self.assertEqual(glyphstr([sub.replacement]), "[ampersand.1 ampersand.2]") def test_substitute_ligature(self): # GSUB LookupType 4 doc = self.parse("feature liga {substitute f f i by f_f_i;} liga;") @@ -1652,13 +1774,15 @@ def test_substitute_missing_by(self): self.assertRaisesRegex( FeatureLibError, 'Expected "by", "from" or explicit lookup references', - self.parse, "feature liga {substitute f f i;} liga;") - + self.parse, + "feature liga {substitute f f i;} liga;", + ) + def test_substitute_invalid_statement(self): self.assertRaisesRegex( FeatureLibError, "Invalid substitution statement", - Parser(self.getpath("GSUB_error.fea"), GLYPHNAMES).parse + Parser(self.getpath("GSUB_error.fea"), GLYPHNAMES).parse, ) def test_subtable(self): @@ -1668,8 +1792,11 @@ def test_subtable(self): def test_table_badEnd(self): self.assertRaisesRegex( - FeatureLibError, 'Expected "GDEF"', self.parse, - "table GDEF {LigatureCaretByPos f_i 400;} ABCD;") + FeatureLibError, + 'Expected "GDEF"', + self.parse, + "table GDEF {LigatureCaretByPos f_i 400;} ABCD;", + ) def test_table_comment(self): for table in "BASE GDEF OS/2 head hhea name vhea".split(): @@ -1680,8 +1807,11 @@ def test_table_comment(self): def test_table_unsupported(self): self.assertRaisesRegex( - FeatureLibError, '"table Foo" is not supported', self.parse, - "table Foo {LigatureCaretByPos f_i 400;} Foo;") + FeatureLibError, + '"table Foo" is not supported', + self.parse, + "table Foo {LigatureCaretByPos f_i 400;} Foo;", + ) def test_valuerecord_format_a_horizontal(self): doc = self.parse("feature liga {valueRecordDef 123 foo;} liga;") @@ -1745,12 +1875,13 @@ def test_valuerecord_format_a_zero_vertical(self): def test_valuerecord_format_a_vertical_contexts_(self): for tag in "vkrn vpal vhal valt".split(): - doc = self.parse( - "feature %s {valueRecordDef 77 foo;} %s;" % (tag, tag)) + doc = self.parse("feature %s {valueRecordDef 77 foo;} %s;" % (tag, tag)) value = doc.statements[0].statements[0].value if value.yAdvance != 77: - self.fail(msg="feature %s should be a vertical context " - "for ValueRecord format A" % tag) + self.fail( + msg="feature %s should be a vertical context " + "for ValueRecord format A" % tag + ) def test_valuerecord_format_b(self): doc = self.parse("feature liga {valueRecordDef <1 2 3 4> foo;} liga;") @@ -1792,7 +1923,8 @@ def test_valuerecord_format_c(self): " " " " " > foo;" - "} liga;") + "} liga;" + ) value = doc.statements[0].statements[0].value self.assertEqual(value.xPlacement, 1) self.assertEqual(value.yPlacement, 2) @@ -1802,9 +1934,11 @@ def test_valuerecord_format_c(self): self.assertEqual(value.yPlaDevice, ((11, 111), (12, 112))) self.assertIsNone(value.xAdvDevice) self.assertEqual(value.yAdvDevice, ((33, -113), (44, -114), (55, 115))) - self.assertEqual(value.asFea(), - "<1 2 3 4 " - " >") + self.assertEqual( + value.asFea(), + "<1 2 3 4 " + " >", + ) def test_valuerecord_format_d(self): doc = self.parse("feature test {valueRecordDef foo;} test;") @@ -1813,13 +1947,20 @@ def test_valuerecord_format_d(self): self.assertEqual(value.asFea(), "") def test_valuerecord_variable_scalar(self): - doc = self.parse("feature test {valueRecordDef <0 (wght=200:-100 wght=900:-150 wdth=150,wght=900:-120) 0 0> foo;} test;") + doc = self.parse( + "feature test {valueRecordDef <0 (wght=200:-100 wght=900:-150 wdth=150,wght=900:-120) 0 0> foo;} test;" + ) value = doc.statements[0].statements[0].value - self.assertEqual(value.asFea(), "<0 (wght=200:-100 wght=900:-150 wdth=150,wght=900:-120) 0 0>") + self.assertEqual( + value.asFea(), + "<0 (wght=200:-100 wght=900:-150 wdth=150,wght=900:-120) 0 0>", + ) def test_valuerecord_named(self): - doc = self.parse("valueRecordDef <1 2 3 4> foo;" - "feature liga {valueRecordDef bar;} liga;") + doc = self.parse( + "valueRecordDef <1 2 3 4> foo;" + "feature liga {valueRecordDef bar;} liga;" + ) value = doc.statements[1].statements[0].value self.assertEqual(value.xPlacement, 1) self.assertEqual(value.yPlacement, 2) @@ -1828,8 +1969,11 @@ def test_valuerecord_named(self): def test_valuerecord_named_unknown(self): self.assertRaisesRegex( - FeatureLibError, "Unknown valueRecordDef \"unknown\"", - self.parse, "valueRecordDef foo;") + FeatureLibError, + 'Unknown valueRecordDef "unknown"', + self.parse, + "valueRecordDef foo;", + ) def test_valuerecord_scoping(self): [foo, liga, smcp] = self.parse( @@ -1843,34 +1987,45 @@ def test_valuerecord_scoping(self): def test_valuerecord_device_value_out_of_range(self): self.assertRaisesRegex( - FeatureLibError, r"Device value out of valid range \(-128..127\)", + FeatureLibError, + r"Device value out of valid range \(-128..127\)", self.parse, "valueRecordDef <1 2 3 4 " - " > foo;") + " > foo;", + ) def test_conditionset(self): doc = self.parse("conditionset heavy { wght 700 900; } heavy;") value = doc.statements[0] self.assertEqual(value.conditions["wght"], (700, 900)) - self.assertEqual(value.asFea(), "conditionset heavy {\n wght 700 900;\n} heavy;\n") + self.assertEqual( + value.asFea(), "conditionset heavy {\n wght 700 900;\n} heavy;\n" + ) doc = self.parse("conditionset heavy { wght 700 900; opsz 17 18;} heavy;") value = doc.statements[0] self.assertEqual(value.conditions["wght"], (700, 900)) self.assertEqual(value.conditions["opsz"], (17, 18)) - self.assertEqual(value.asFea(), "conditionset heavy {\n wght 700 900;\n opsz 17 18;\n} heavy;\n") + self.assertEqual( + value.asFea(), + "conditionset heavy {\n wght 700 900;\n opsz 17 18;\n} heavy;\n", + ) def test_conditionset_same_axis(self): self.assertRaisesRegex( - FeatureLibError, r"Repeated condition for axis wght", + FeatureLibError, + r"Repeated condition for axis wght", self.parse, - "conditionset heavy { wght 700 900; wght 100 200; } heavy;") + "conditionset heavy { wght 700 900; wght 100 200; } heavy;", + ) def test_conditionset_float(self): doc = self.parse("conditionset heavy { wght 700.0 900.0; } heavy;") value = doc.statements[0] self.assertEqual(value.conditions["wght"], (700.0, 900.0)) - self.assertEqual(value.asFea(), "conditionset heavy {\n wght 700.0 900.0;\n} heavy;\n") + self.assertEqual( + value.asFea(), "conditionset heavy {\n wght 700.0 900.0;\n} heavy;\n" + ) def test_variation(self): doc = self.parse("variation rvrn heavy { sub a by b; } rvrn;") @@ -1886,20 +2041,30 @@ def test_languagesystem(self): self.assertRaisesRegex( FeatureLibError, '"dflt" is not a valid script tag; use "DFLT" instead', - self.parse, "languagesystem dflt dflt;") + self.parse, + "languagesystem dflt dflt;", + ) self.assertRaisesRegex( FeatureLibError, '"DFLT" is not a valid language tag; use "dflt" instead', - self.parse, "languagesystem latn DFLT;") + self.parse, + "languagesystem latn DFLT;", + ) self.assertRaisesRegex( - FeatureLibError, "Expected ';'", - self.parse, "languagesystem latn DEU") + FeatureLibError, "Expected ';'", self.parse, "languagesystem latn DEU" + ) self.assertRaisesRegex( - FeatureLibError, "longer than 4 characters", - self.parse, "languagesystem foobar DEU;") + FeatureLibError, + "longer than 4 characters", + self.parse, + "languagesystem foobar DEU;", + ) self.assertRaisesRegex( - FeatureLibError, "longer than 4 characters", - self.parse, "languagesystem latn FOOBAR;") + FeatureLibError, + "longer than 4 characters", + self.parse, + "languagesystem latn FOOBAR;", + ) def test_empty_statement_ignored(self): doc = self.parse("feature test {;} test;") @@ -1945,4 +2110,5 @@ def test_resolve_undefined(self): if __name__ == "__main__": import sys + sys.exit(unittest.main())