From 16b7d424dd62c16b6325cc23b4b0b5c8c33dc216 Mon Sep 17 00:00:00 2001 From: justvanrossum Date: Mon, 16 Apr 2018 10:21:19 +0200 Subject: [PATCH 01/11] Module containing a function to add conditional substitutions to a variable font --- Lib/fontTools/varLib/featureVars.py | 402 ++++++++++++++++++++++++++++ 1 file changed, 402 insertions(+) create mode 100644 Lib/fontTools/varLib/featureVars.py diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py new file mode 100644 index 000000000..954d8110a --- /dev/null +++ b/Lib/fontTools/varLib/featureVars.py @@ -0,0 +1,402 @@ +from __future__ import print_function, absolute_import, division + +from fontTools.ttLib import newTable +from fontTools.ttLib.tables import otTables as ot +from fontTools.otlLib.builder import buildLookup, buildSingleSubstSubtable +from fontTools.varLib.models import normalizeValue + + +def addFeatureVariations(font, conditionalSubstitutions): + """Add conditional substitutions to a Variable Font. + + The `conditionalSubstitutions` argument is a list of (Region, Substitutions) + tuples. + + A Region is a list of Spaces. A Space is a dict mapping axisTags to + (minValue, maxValue) tuples. Irrelevant axes may be omitted. + A Space represents a 'rectangular' subset of an N-dimensional design space. + A Region represents a more complex subset of an N-dimensional design space, + ie. the union of all the Spaces in the Region. + For efficiency, Spaces within a Region should ideally not overlap, but + functionality is not compromised if they do. + + The minimum and maximum values are expressed in raw user coordinates, and + are internally normalized without going through the `avar` mapping. + + A Substitution is a dict mapping source glyph names to substitute glyph names. + """ + + # Example: + # + # >>> f = TTFont(srcPath) + # >>> condSubst = [ + # ... # A list of (Region, Substitution) tuples. + # ... ([{"wght": (0.5, 1.0)}], {"dollar": "dollar.rvrn"}), + # ... ([{"wdth": (0.5, 1.0)}], {"cent": "cent.rvrn"}), + # ... ] + # >>> addFeatureVariations(f, condSubst) + # >>> f.save(dstPath) + + defaultSpace = {} + axisMap = {} + for axis in font["fvar"].axes: + defaultSpace[axis.axisTag] = (axis.minValue, axis.maxValue) + axisMap[axis.axisTag] = (axis.minValue, axis.defaultValue, axis.maxValue) + + # Since the FeatureVariations table will only ever match one rule at a time, + # we will make new rules for all possible combinations of our input, so we + # can indirectly support overlapping rules. + explodedConditionalSubstitutions = [] + for permutation in getPermutations(len(conditionalSubstitutions)): + regions = [] + lookups = [] + for index in permutation: + regions.append(conditionalSubstitutions[index][0]) + lookups.append(conditionalSubstitutions[index][1]) + if not regions: + continue + intersection = regions[0] + for region in regions[1:]: + intersection = intersectRegions(intersection, region) + for space in intersection: + # Remove default values, so we don't generate redundant ConditionSets + space = cleanupSpace(space, defaultSpace) + if space: + space = normalizeSpace(space, axisMap) + explodedConditionalSubstitutions.append((space, lookups)) + + addFeatureVariationsRaw(font, explodedConditionalSubstitutions) + + +def getPermutations(numRules): + """Given a number of rules, return a list of all combinations by index. + The list is reverse-sorted by the number of indices, so we get the most + specialized rules first. + + >>> getPermutations(0) + [[]] + >>> getPermutations(1) + [[0], []] + >>> getPermutations(2) + [[0, 1], [0], [1], []] + >>> getPermutations(3) + [[0, 1, 2], [0, 1], [0, 2], [1, 2], [0], [1], [2], []] + + """ + bitNumbers = range(numRules) + permutations = [] + for num in range(2 ** numRules): + permutation = [] + for bitNum in bitNumbers: + if num & (1 << bitNum): + permutation.append(bitNum) + permutations.append(permutation) + # reverse sort by the number of indices + permutations.sort(key=lambda x: len(x), reverse=True) + return permutations + + +# +# Region and Space support +# +# Terminology: +# +# A 'Space' is a dict representing a "rectangular" bit of N-dimensional space. +# The keys in the dict are axis tags, the values are (minValue, maxValue) tuples. +# Missing dimensions (keys) are substituted by the default min and max values +# from the corresponding axes. +# +# A 'Region' is a list of Space dicts, representing the union of the Spaces, +# therefore representing a more complex subset of design space. +# + +def intersectRegions(region1, region2): + """Return the region intersecting `region1` and `region2`. + + >>> intersectRegions([], []) + [] + >>> intersectRegions([{'wdth': (0.0, 1.0)}], []) + [] + >>> intersectRegions([{'wdth': (0.0, 1.0)}], [{'wght': (-1.0, 0.0)}]) + [{'wdth': (0.0, 1.0), 'wght': (-1.0, 0.0)}] + >>> intersectRegions([{'wdth': (0.0, 1.0), 'wght': (-0.5, 0.5)}], [{'wght': (-1.0, 0.0)}]) + [{'wdth': (0.0, 1.0), 'wght': (-0.5, 0.0)}] + + """ + region = [] + for space1 in region1: + for space2 in region2: + space = intersectSpaces(space1, space2) + if space is not None: + region.append(space) + return region + + +def intersectSpaces(space1, space2): + """Return the space intersected by `space1` and `space2`, or None if there + is no intersection. + + >>> intersectSpaces({}, {}) + {} + >>> intersectSpaces({'wdth': (-0.5, 0.5)}, {}) + {'wdth': (-0.5, 0.5)} + >>> intersectSpaces({'wdth': (-0.5, 0.5)}, {'wdth': (0.0, 1.0)}) + {'wdth': (0.0, 0.5)} + >>> intersectSpaces({'wdth': (-0.5, 0.5), 'wght': (0.0, 0.5)}, {'wdth': (0.0, 1.0), 'wght': (0.25, 0.75)}) + {'wdth': (0.0, 0.5), 'wght': (0.25, 0.5)} + >>> intersectSpaces({'wdth': (-0.5, 0.5)}, {'wght': (0.0, 1.0)}) + {'wdth': (-0.5, 0.5), 'wght': (0.0, 1.0)} + + """ + space = {} + space.update(space1) + space.update(space2) + for axisTag in set(space1) & set(space2): + min1, max1 = space1[axisTag] + min2, max2 = space2[axisTag] + minimum = max(min1, min2) + maximum = min(max1, max2) + if not minimum < maximum: + return None + space[axisTag] = minimum, maximum + return space + + +def cleanupSpace(space, defaultSpace): + """Return a sparse copy of `space`, without redundant (default) values. + + >>> cleanupSpace({}, {'wdth': (-1.0, 1.0), 'wght': (-1.0, 1.0)}) + {} + >>> cleanupSpace({'wdth': (0.0, 1.0)}, {'wdth': (-1.0, 1.0), 'wght': (-1.0, 1.0)}) + {'wdth': (0.0, 1.0)} + >>> cleanupSpace({'wdth': (-1.0, 1.0)}, {'wdth': (-1.0, 1.0), 'wght': (-1.0, 1.0)}) + {} + + """ + return {tag: limit for tag, limit in space.items() if limit != defaultSpace[tag]} + + +def normalizeSpace(space, axisMap): + """Convert the min/max values in the `space` dict to normalized + design space values.""" + space = {tag: (normalizeValue(minValue, axisMap[tag]), normalizeValue(maxValue, axisMap[tag])) + for tag, (minValue, maxValue) in space.items()} + return space + + +# +# Low level implementation +# + +def addFeatureVariationsRaw(font, conditionalSubstitutions): + """Low level implementation of addFeatureVariations that directly + models the possibilities of the FeatureVariations table.""" + + # + # assert there is no 'rvrn' feature + # make dummy 'rvrn' feature with no lookups + # sort features, get 'rvrn' feature index + # add 'rvrn' feature to all scripts + # make lookups + # add feature variations + # + + 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 + + for feature in gsub.FeatureList.FeatureRecord: + assert feature.FeatureTag != 'rvrn' + + rvrnFeature = buildFeatureRecord('rvrn', []) + gsub.FeatureList.FeatureRecord.append(rvrnFeature) + + sortFeatureList(gsub) + rvrnFeatureIndex = gsub.FeatureList.FeatureRecord.index(rvrnFeature) + + for scriptRecord in gsub.ScriptList.ScriptRecord: + for langSys in [scriptRecord.Script.DefaultLangSys] + scriptRecord.Script.LangSysRecord: + langSys.FeatureIndex.append(rvrnFeatureIndex) + + # 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: + conditionTable = [] + for axisTag, (minValue, maxValue) in sorted(conditionSet.items()): + assert minValue < maxValue + ct = buildConditionTable(axisIndices[axisTag], minValue, maxValue) + conditionTable.append(ct) + + lookupIndices = [lookupMap[subst] for subst in substitutions] + record = buildFeatureTableSubstitutionRecord(rvrnFeatureIndex, lookupIndices) + featureVariationRecords.append(buildFeatureVariationRecord(conditionTable, [record])) + + gsub.FeatureVariations = buildFeatureVariations(featureVariationRecords) + + +# +# Building GSUB/FeatureVariations internals +# + +def buildGSUB(): + """Build a GSUB table from scratch.""" + fontTable = newTable("GSUB") + gsub = fontTable.table = ot.GSUB() + gsub.Version = 0x00010001 # allow gsub.FeatureVariations + + gsub.ScriptList = ot.ScriptList() + gsub.ScriptList.ScriptRecord = [] + gsub.FeatureList = ot.FeatureList() + gsub.FeatureList.FeatureRecord = [] + gsub.LookupList = ot.LookupList() + gsub.LookupList.Lookup = [] + + srec = ot.ScriptRecord() + srec.ScriptTag = 'DFLT' + srec.Script = ot.Script() + srec.Script.DefaultLangSys = None + srec.Script.LangSysRecord = [] + + langrec = ot.LangSysRecord() + langrec.LangSys = ot.LangSys() + langrec.LangSys.ReqFeatureIndex = 0xFFFF + langrec.LangSys.FeatureIndex = [0] + srec.Script.DefaultLangSys = langrec.LangSys + + gsub.ScriptList.ScriptRecord.append(srec) + gsub.FeatureVariations = None + + return fontTable + + +def makeSubstitutionsHashable(conditionalSubstitutions): + """Turn all the substitution dictionaries in sorted tuples of tuples so + they are hashable, to detect duplicates so we don't write out redundant + data.""" + allSubstitutions = set() + condSubst = [] + for conditionSet, substitutionMaps in conditionalSubstitutions: + substitutions = [] + for substitutionMap in substitutionMaps: + subst = tuple(sorted(substitutionMap.items())) + substitutions.append(subst) + allSubstitutions.add(subst) + condSubst.append((conditionSet, substitutions)) + return condSubst, sorted(allSubstitutions) + + +def buildSubstitutionLookups(gsub, allSubstitutions): + """Build the lookups for the glyph substitutions, return a dict mapping + the substitution to lookup indices.""" + firstIndex = len(gsub.LookupList.Lookup) + lookupMap = {} + for i, substitutionMap in enumerate(allSubstitutions): + lookupMap[substitutionMap] = i + firstIndex + + for subst in allSubstitutions: + substMap = dict(subst) + lookup = buildLookup([buildSingleSubstSubtable(substMap)]) + gsub.LookupList.Lookup.append(lookup) + assert gsub.LookupList.Lookup[lookupMap[subst]] is lookup + return lookupMap + + +def buildFeatureVariations(featureVariationRecords): + """Build the FeatureVariations subtable.""" + fv = ot.FeatureVariations() + fv.Version = 0x00010000 + fv.FeatureVariationRecord = featureVariationRecords + return fv + +def buildFeatureRecord(featureTag, lookupListIndices): + """Build a FeatureRecord.""" + fr = ot.FeatureRecord() + fr.FeatureTag = featureTag + fr.Feature = ot.Feature() + fr.Feature.LookupListIndex = lookupListIndices + return fr + + +def buildFeatureVariationRecord(conditionTable, substitutionRecords): + """Build a FeatureVariationRecord.""" + fvr = ot.FeatureVariationRecord() + fvr.ConditionSet = ot.ConditionSet() + fvr.ConditionSet.ConditionTable = conditionTable + fvr.FeatureTableSubstitution = ot.FeatureTableSubstitution() + fvr.FeatureTableSubstitution.Version = 0x00010001 + fvr.FeatureTableSubstitution.SubstitutionRecord = substitutionRecords + return fvr + + +def buildFeatureTableSubstitutionRecord(featureIndex, lookupListIndices): + """Build a FeatureTableSubstitutionRecord.""" + ftsr = ot.FeatureTableSubstitutionRecord() + ftsr.FeatureIndex = featureIndex + ftsr.Feature = ot.Feature() + ftsr.Feature.LookupListIndex = lookupListIndices + return ftsr + + +def buildConditionTable(axisIndex, filterRangeMinValue, filterRangeMaxValue): + """Build a ConditionTable.""" + ct = ot.ConditionTable() + ct.Format = 1 + ct.AxisIndex = axisIndex + ct.FilterRangeMinValue = filterRangeMinValue + ct.FilterRangeMaxValue = filterRangeMaxValue + return ct + + +def sortFeatureList(table): + """Sort the feature list by feature tag, and remap the feature indices + elsewhere. This is needed after the feature list has been modified. + """ + # decorate, sort, undecorate, because we need to make an index remapping table + tagIndexFea = [(fea.FeatureTag, index, fea) for index, fea in enumerate(table.FeatureList.FeatureRecord)] + tagIndexFea.sort() + table.FeatureList.FeatureRecord = [fea for tag, index, fea in tagIndexFea] + featureRemap = dict(zip([index for tag, index, fea in tagIndexFea], range(len(tagIndexFea)))) + + # Remap the feature indices + remapFeatures(table, featureRemap) + + +def remapFeatures(table, featureRemap): + """Go through the scripts list, and remap feature indices.""" + for scriptIndex, script in enumerate(table.ScriptList.ScriptRecord): + defaultLangSys = script.Script.DefaultLangSys + if defaultLangSys is not None: + _remapLangSys(defaultLangSys, featureRemap) + for langSysRecordIndex, langSysRec in enumerate(script.Script.LangSysRecord): + langSys = langSysRec.LangSys + _remapLangSys(langSys, featureRemap) + + if hasattr(table, "FeatureVariations") and table.FeatureVariations is not None: + for fvr in table.FeatureVariations.FeatureVariationRecord: + for ftsr in fvr.FeatureTableSubstitution.SubstitutionRecord: + ftsr.FeatureIndex = featureRemap[ftsr.FeatureIndex] + + +def _remapLangSys(langSys, featureRemap): + if langSys.ReqFeatureIndex != 0xffff: + langSys.ReqFeatureIndex = featureRemap[langSys.ReqFeatureIndex] + langSys.FeatureIndex = [featureRemap[index] for index in langSys.FeatureIndex] + + +if __name__ == "__main__": + import doctest + doctest.testmod() From 4256e6c6bf48eec1b4c8140650092b335b328399 Mon Sep 17 00:00:00 2001 From: justvanrossum Date: Mon, 16 Apr 2018 10:33:30 +0200 Subject: [PATCH 02/11] make doctests independent of dict order repr --- Lib/fontTools/varLib/featureVars.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py index 954d8110a..1490fe644 100644 --- a/Lib/fontTools/varLib/featureVars.py +++ b/Lib/fontTools/varLib/featureVars.py @@ -117,10 +117,12 @@ def intersectRegions(region1, region2): [] >>> intersectRegions([{'wdth': (0.0, 1.0)}], []) [] - >>> intersectRegions([{'wdth': (0.0, 1.0)}], [{'wght': (-1.0, 0.0)}]) - [{'wdth': (0.0, 1.0), 'wght': (-1.0, 0.0)}] - >>> intersectRegions([{'wdth': (0.0, 1.0), 'wght': (-0.5, 0.5)}], [{'wght': (-1.0, 0.0)}]) - [{'wdth': (0.0, 1.0), 'wght': (-0.5, 0.0)}] + >>> expected = [{'wdth': (0.0, 1.0), 'wght': (-1.0, 0.0)}] + >>> expected == intersectRegions([{'wdth': (0.0, 1.0)}], [{'wght': (-1.0, 0.0)}]) + True + >>> expected = [{'wdth': (0.0, 1.0), 'wght': (-0.5, 0.0)}] + >>> expected == intersectRegions([{'wdth': (0.0, 1.0), 'wght': (-0.5, 0.5)}], [{'wght': (-1.0, 0.0)}]) + True """ region = [] @@ -142,10 +144,12 @@ def intersectSpaces(space1, space2): {'wdth': (-0.5, 0.5)} >>> intersectSpaces({'wdth': (-0.5, 0.5)}, {'wdth': (0.0, 1.0)}) {'wdth': (0.0, 0.5)} - >>> intersectSpaces({'wdth': (-0.5, 0.5), 'wght': (0.0, 0.5)}, {'wdth': (0.0, 1.0), 'wght': (0.25, 0.75)}) - {'wdth': (0.0, 0.5), 'wght': (0.25, 0.5)} - >>> intersectSpaces({'wdth': (-0.5, 0.5)}, {'wght': (0.0, 1.0)}) - {'wdth': (-0.5, 0.5), 'wght': (0.0, 1.0)} + >>> expected = {'wdth': (0.0, 0.5), 'wght': (0.25, 0.5)} + >>> expected == intersectSpaces({'wdth': (-0.5, 0.5), 'wght': (0.0, 0.5)}, {'wdth': (0.0, 1.0), 'wght': (0.25, 0.75)}) + True + >>> expected = {'wdth': (-0.5, 0.5), 'wght': (0.0, 1.0)} + >>> expected == intersectSpaces({'wdth': (-0.5, 0.5)}, {'wght': (0.0, 1.0)}) + True """ space = {} From 081ca1327c69ad74839b95d3c5096868a5f25532 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 16 Apr 2018 18:38:58 +0200 Subject: [PATCH 03/11] featureVars: modify normalization using avar maps --- Lib/fontTools/varLib/featureVars.py | 38 +++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py index 1490fe644..7b7676bf0 100644 --- a/Lib/fontTools/varLib/featureVars.py +++ b/Lib/fontTools/varLib/featureVars.py @@ -4,6 +4,7 @@ from fontTools.ttLib import newTable from fontTools.ttLib.tables import otTables as ot from fontTools.otlLib.builder import buildLookup, buildSingleSubstSubtable from fontTools.varLib.models import normalizeValue +from fontTools.varLib import _DesignspaceAxis def addFeatureVariations(font, conditionalSubstitutions): @@ -21,7 +22,8 @@ def addFeatureVariations(font, conditionalSubstitutions): functionality is not compromised if they do. The minimum and maximum values are expressed in raw user coordinates, and - are internally normalized without going through the `avar` mapping. + are internally normalized using the `avar` mapping, if present, else using + the default mapping. A Substitution is a dict mapping source glyph names to substitute glyph names. """ @@ -38,10 +40,12 @@ def addFeatureVariations(font, conditionalSubstitutions): # >>> f.save(dstPath) defaultSpace = {} - axisMap = {} + axes = {} for axis in font["fvar"].axes: defaultSpace[axis.axisTag] = (axis.minValue, axis.maxValue) - axisMap[axis.axisTag] = (axis.minValue, axis.defaultValue, axis.maxValue) + axes[axis.axisTag] = (axis.minValue, axis.defaultValue, axis.maxValue) + + maps = font['avar'].segments if 'avar' in font else None # Since the FeatureVariations table will only ever match one rule at a time, # we will make new rules for all possible combinations of our input, so we @@ -62,7 +66,9 @@ def addFeatureVariations(font, conditionalSubstitutions): # Remove default values, so we don't generate redundant ConditionSets space = cleanupSpace(space, defaultSpace) if space: - space = normalizeSpace(space, axisMap) + space = normalizeSpace(space, axes) + if maps is not None: + space = mapSpace(space, maps) explodedConditionalSubstitutions.append((space, lookups)) addFeatureVariationsRaw(font, explodedConditionalSubstitutions) @@ -180,14 +186,30 @@ def cleanupSpace(space, defaultSpace): return {tag: limit for tag, limit in space.items() if limit != defaultSpace[tag]} -def normalizeSpace(space, axisMap): +def normalizeSpace(space, axes): """Convert the min/max values in the `space` dict to normalized - design space values.""" - space = {tag: (normalizeValue(minValue, axisMap[tag]), normalizeValue(maxValue, axisMap[tag])) - for tag, (minValue, maxValue) in space.items()} + design space values. + The `axes` argument is a dictionary keyed by axis tags, containing + (minimum, default, maximum) tuples from the fvar table. + """ + space = {tag: (normalizeValue(minValue, axes[tag]), + normalizeValue(maxValue, axes[tag])) + for tag, (minValue, maxValue) in space.items()} return space +def mapSpace(space, maps): + """Modify normalized min/max values in the `space` dict using axis maps + from avar table. + """ + return { + tag: ( + _DesignspaceAxis._map(minValue, maps[tag]), + _DesignspaceAxis._map(maxValue, maps[tag]) + ) for tag, (minValue, maxValue) in space.items() + } + + # # Low level implementation # From 2002a6c92c467857ff9f5e6cab421986415ee960 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 17 Apr 2018 12:23:38 +0200 Subject: [PATCH 04/11] Revert "featureVars: modify normalization using avar maps" This reverts commit 081ca1327c69ad74839b95d3c5096868a5f25532. https://github.com/fonttools/fonttools/pull/1240#issuecomment-381923485 --- Lib/fontTools/varLib/featureVars.py | 38 ++++++----------------------- 1 file changed, 8 insertions(+), 30 deletions(-) diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py index 7b7676bf0..1490fe644 100644 --- a/Lib/fontTools/varLib/featureVars.py +++ b/Lib/fontTools/varLib/featureVars.py @@ -4,7 +4,6 @@ from fontTools.ttLib import newTable from fontTools.ttLib.tables import otTables as ot from fontTools.otlLib.builder import buildLookup, buildSingleSubstSubtable from fontTools.varLib.models import normalizeValue -from fontTools.varLib import _DesignspaceAxis def addFeatureVariations(font, conditionalSubstitutions): @@ -22,8 +21,7 @@ def addFeatureVariations(font, conditionalSubstitutions): functionality is not compromised if they do. The minimum and maximum values are expressed in raw user coordinates, and - are internally normalized using the `avar` mapping, if present, else using - the default mapping. + are internally normalized without going through the `avar` mapping. A Substitution is a dict mapping source glyph names to substitute glyph names. """ @@ -40,12 +38,10 @@ def addFeatureVariations(font, conditionalSubstitutions): # >>> f.save(dstPath) defaultSpace = {} - axes = {} + axisMap = {} for axis in font["fvar"].axes: defaultSpace[axis.axisTag] = (axis.minValue, axis.maxValue) - axes[axis.axisTag] = (axis.minValue, axis.defaultValue, axis.maxValue) - - maps = font['avar'].segments if 'avar' in font else None + axisMap[axis.axisTag] = (axis.minValue, axis.defaultValue, axis.maxValue) # Since the FeatureVariations table will only ever match one rule at a time, # we will make new rules for all possible combinations of our input, so we @@ -66,9 +62,7 @@ def addFeatureVariations(font, conditionalSubstitutions): # Remove default values, so we don't generate redundant ConditionSets space = cleanupSpace(space, defaultSpace) if space: - space = normalizeSpace(space, axes) - if maps is not None: - space = mapSpace(space, maps) + space = normalizeSpace(space, axisMap) explodedConditionalSubstitutions.append((space, lookups)) addFeatureVariationsRaw(font, explodedConditionalSubstitutions) @@ -186,30 +180,14 @@ def cleanupSpace(space, defaultSpace): return {tag: limit for tag, limit in space.items() if limit != defaultSpace[tag]} -def normalizeSpace(space, axes): +def normalizeSpace(space, axisMap): """Convert the min/max values in the `space` dict to normalized - design space values. - The `axes` argument is a dictionary keyed by axis tags, containing - (minimum, default, maximum) tuples from the fvar table. - """ - space = {tag: (normalizeValue(minValue, axes[tag]), - normalizeValue(maxValue, axes[tag])) - for tag, (minValue, maxValue) in space.items()} + design space values.""" + space = {tag: (normalizeValue(minValue, axisMap[tag]), normalizeValue(maxValue, axisMap[tag])) + for tag, (minValue, maxValue) in space.items()} return space -def mapSpace(space, maps): - """Modify normalized min/max values in the `space` dict using axis maps - from avar table. - """ - return { - tag: ( - _DesignspaceAxis._map(minValue, maps[tag]), - _DesignspaceAxis._map(maxValue, maps[tag]) - ) for tag, (minValue, maxValue) in space.items() - } - - # # Low level implementation # From 0c209483414dd91d321cf152a4f94ee571acee0e Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 17 Apr 2018 12:31:14 +0200 Subject: [PATCH 05/11] clarify coordinates are expressed in 'raw design' values, not 'user' this is what tripped the whole misunderstanding --- Lib/fontTools/varLib/featureVars.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py index 1490fe644..d76bfa72b 100644 --- a/Lib/fontTools/varLib/featureVars.py +++ b/Lib/fontTools/varLib/featureVars.py @@ -20,7 +20,7 @@ def addFeatureVariations(font, conditionalSubstitutions): For efficiency, Spaces within a Region should ideally not overlap, but functionality is not compromised if they do. - The minimum and maximum values are expressed in raw user coordinates, and + The minimum and maximum values are expressed in raw design coordinates, and are internally normalized without going through the `avar` mapping. A Substitution is a dict mapping source glyph names to substitute glyph names. From d7b4d0688240664efb4bcbba9ff4ef018e2d6e02 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 17 Apr 2018 14:10:50 +0200 Subject: [PATCH 06/11] [varLib] minor: rename variables for clarity; fix mixed tab-space indent confuses my vim --- Lib/fontTools/varLib/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Lib/fontTools/varLib/__init__.py b/Lib/fontTools/varLib/__init__.py index 5b11286e0..3d3f74704 100644 --- a/Lib/fontTools/varLib/__init__.py +++ b/Lib/fontTools/varLib/__init__.py @@ -10,11 +10,11 @@ ttf-interpolatable files for the masters and build a variable-font from them. Such ttf-interpolatable and designspace files can be generated from a Glyphs source, eg., using noto-source as an example: - $ fontmake -o ttf-interpolatable -g NotoSansArabic-MM.glyphs + $ fontmake -o ttf-interpolatable -g NotoSansArabic-MM.glyphs Then you can make a variable-font this way: - $ fonttools varLib master_ufo/NotoSansArabic.designspace + $ fonttools varLib master_ufo/NotoSansArabic.designspace API *will* change in near future. """ @@ -171,7 +171,7 @@ def _add_avar(font, axes): def _add_stat(font, axes): if "STAT" in font: - return + return nameTable = font['name'] @@ -688,8 +688,8 @@ def load_designspace(designspace_filename): # Normalize master locations - normalized_master_locs = [o['location'] for o in masters] - log.info("Internal master locations:\n%s", pformat(normalized_master_locs)) + internal_master_locs = [o['location'] for o in masters] + log.info("Internal master locations:\n%s", pformat(internal_master_locs)) # TODO This mapping should ideally be moved closer to logic in _add_fvar/avar internal_axis_supports = {} @@ -698,7 +698,7 @@ def load_designspace(designspace_filename): internal_axis_supports[axis.name] = [axis.map_forward(v) for v in triple] log.info("Internal axis supports:\n%s", pformat(internal_axis_supports)) - normalized_master_locs = [models.normalizeLocation(m, internal_axis_supports) for m in normalized_master_locs] + normalized_master_locs = [models.normalizeLocation(m, internal_axis_supports) for m in internal_master_locs] log.info("Normalized master locations:\n%s", pformat(normalized_master_locs)) From 9a04811ec220e84706c78e991c650b94652c665d Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 17 Apr 2018 15:58:48 +0200 Subject: [PATCH 07/11] [featureVars] use itertools to get combinations of indices and rename getPermutations to iterAllCombinations. It's not really permutations we are after here, but more combinations of indexes sorted by decreasing length, from more specific to less --- Lib/fontTools/varLib/featureVars.py | 44 ++++++++++++----------------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py index d76bfa72b..b0c471900 100644 --- a/Lib/fontTools/varLib/featureVars.py +++ b/Lib/fontTools/varLib/featureVars.py @@ -4,6 +4,7 @@ from fontTools.ttLib import newTable from fontTools.ttLib.tables import otTables as ot from fontTools.otlLib.builder import buildLookup, buildSingleSubstSubtable from fontTools.varLib.models import normalizeValue +import itertools def addFeatureVariations(font, conditionalSubstitutions): @@ -47,10 +48,10 @@ def addFeatureVariations(font, conditionalSubstitutions): # we will make new rules for all possible combinations of our input, so we # can indirectly support overlapping rules. explodedConditionalSubstitutions = [] - for permutation in getPermutations(len(conditionalSubstitutions)): + for combination in iterAllCombinations(len(conditionalSubstitutions)): regions = [] lookups = [] - for index in permutation: + for index in combination: regions.append(conditionalSubstitutions[index][0]) lookups.append(conditionalSubstitutions[index][1]) if not regions: @@ -68,32 +69,23 @@ def addFeatureVariations(font, conditionalSubstitutions): addFeatureVariationsRaw(font, explodedConditionalSubstitutions) -def getPermutations(numRules): - """Given a number of rules, return a list of all combinations by index. - The list is reverse-sorted by the number of indices, so we get the most - specialized rules first. - - >>> getPermutations(0) - [[]] - >>> getPermutations(1) - [[0], []] - >>> getPermutations(2) - [[0, 1], [0], [1], []] - >>> getPermutations(3) - [[0, 1, 2], [0, 1], [0, 2], [1, 2], [0], [1], [2], []] +def iterAllCombinations(numRules): + """Given a number of rules, yield all the combinations of indices, sorted + by decreasing length, so we get the most specialized rules first. + >>> list(iterAllCombinations(0)) + [] + >>> list(iterAllCombinations(1)) + [(0,)] + >>> list(iterAllCombinations(2)) + [(0, 1), (0,), (1,)] + >>> list(iterAllCombinations(3)) + [(0, 1, 2), (0, 1), (0, 2), (1, 2), (0,), (1,), (2,)] """ - bitNumbers = range(numRules) - permutations = [] - for num in range(2 ** numRules): - permutation = [] - for bitNum in bitNumbers: - if num & (1 << bitNum): - permutation.append(bitNum) - permutations.append(permutation) - # reverse sort by the number of indices - permutations.sort(key=lambda x: len(x), reverse=True) - return permutations + indices = range(numRules) + for length in range(numRules, 0, -1): + for combinations in itertools.combinations(indices, length): + yield combinations # From 0f1c6b3cf4465a5cf47c959ec5a24e28c3bb352f Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Tue, 17 Apr 2018 16:53:33 +0200 Subject: [PATCH 08/11] [featureVars] add doctests for non intersecting spaces or regions --- Lib/fontTools/varLib/featureVars.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py index b0c471900..afa955401 100644 --- a/Lib/fontTools/varLib/featureVars.py +++ b/Lib/fontTools/varLib/featureVars.py @@ -115,6 +115,10 @@ def intersectRegions(region1, region2): >>> expected = [{'wdth': (0.0, 1.0), 'wght': (-0.5, 0.0)}] >>> expected == intersectRegions([{'wdth': (0.0, 1.0), 'wght': (-0.5, 0.5)}], [{'wght': (-1.0, 0.0)}]) True + >>> intersectRegions( + ... [{'wdth': (0.0, 1.0), 'wght': (-0.5, 0.5)}], + ... [{'wdth': (-1.0, 0.0), 'wght': (-1.0, 0.0)}]) + [] """ region = [] @@ -142,6 +146,7 @@ def intersectSpaces(space1, space2): >>> expected = {'wdth': (-0.5, 0.5), 'wght': (0.0, 1.0)} >>> expected == intersectSpaces({'wdth': (-0.5, 0.5)}, {'wght': (0.0, 1.0)}) True + >>> intersectSpaces({'wdth': (-0.5, 0)}, {'wdth': (0.1, 0.5)}) """ space = {} From 978967983cc6e05eabd33fe77ac4c6343de3791d Mon Sep 17 00:00:00 2001 From: justvanrossum Date: Wed, 18 Apr 2018 08:51:45 +0200 Subject: [PATCH 09/11] removing normalization step: min/max values are now expected to be given in normalized coordinates --- Lib/fontTools/varLib/featureVars.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py index afa955401..fbd1cbcd0 100644 --- a/Lib/fontTools/varLib/featureVars.py +++ b/Lib/fontTools/varLib/featureVars.py @@ -3,7 +3,6 @@ from __future__ import print_function, absolute_import, division from fontTools.ttLib import newTable from fontTools.ttLib.tables import otTables as ot from fontTools.otlLib.builder import buildLookup, buildSingleSubstSubtable -from fontTools.varLib.models import normalizeValue import itertools @@ -21,8 +20,7 @@ def addFeatureVariations(font, conditionalSubstitutions): For efficiency, Spaces within a Region should ideally not overlap, but functionality is not compromised if they do. - The minimum and maximum values are expressed in raw design coordinates, and - are internally normalized without going through the `avar` mapping. + The minimum and maximum values are expressed in normalized coordinates. A Substitution is a dict mapping source glyph names to substitute glyph names. """ @@ -63,7 +61,6 @@ def addFeatureVariations(font, conditionalSubstitutions): # Remove default values, so we don't generate redundant ConditionSets space = cleanupSpace(space, defaultSpace) if space: - space = normalizeSpace(space, axisMap) explodedConditionalSubstitutions.append((space, lookups)) addFeatureVariationsRaw(font, explodedConditionalSubstitutions) @@ -177,14 +174,6 @@ def cleanupSpace(space, defaultSpace): return {tag: limit for tag, limit in space.items() if limit != defaultSpace[tag]} -def normalizeSpace(space, axisMap): - """Convert the min/max values in the `space` dict to normalized - design space values.""" - space = {tag: (normalizeValue(minValue, axisMap[tag]), normalizeValue(maxValue, axisMap[tag])) - for tag, (minValue, maxValue) in space.items()} - return space - - # # Low level implementation # From 04dc7339fde2a0811b6fad4e65666278c4b17c0f Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 18 Apr 2018 12:07:06 +0100 Subject: [PATCH 10/11] [featureVars] remove unused fvar code; normalized default space is always (-1.0, 1.0) --- Lib/fontTools/varLib/featureVars.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py index fbd1cbcd0..306e933c5 100644 --- a/Lib/fontTools/varLib/featureVars.py +++ b/Lib/fontTools/varLib/featureVars.py @@ -36,12 +36,6 @@ def addFeatureVariations(font, conditionalSubstitutions): # >>> addFeatureVariations(f, condSubst) # >>> f.save(dstPath) - defaultSpace = {} - axisMap = {} - for axis in font["fvar"].axes: - defaultSpace[axis.axisTag] = (axis.minValue, axis.maxValue) - axisMap[axis.axisTag] = (axis.minValue, axis.defaultValue, axis.maxValue) - # Since the FeatureVariations table will only ever match one rule at a time, # we will make new rules for all possible combinations of our input, so we # can indirectly support overlapping rules. @@ -59,7 +53,7 @@ def addFeatureVariations(font, conditionalSubstitutions): intersection = intersectRegions(intersection, region) for space in intersection: # Remove default values, so we don't generate redundant ConditionSets - space = cleanupSpace(space, defaultSpace) + space = cleanupSpace(space) if space: explodedConditionalSubstitutions.append((space, lookups)) @@ -160,18 +154,18 @@ def intersectSpaces(space1, space2): return space -def cleanupSpace(space, defaultSpace): +def cleanupSpace(space): """Return a sparse copy of `space`, without redundant (default) values. - >>> cleanupSpace({}, {'wdth': (-1.0, 1.0), 'wght': (-1.0, 1.0)}) + >>> cleanupSpace({}) {} - >>> cleanupSpace({'wdth': (0.0, 1.0)}, {'wdth': (-1.0, 1.0), 'wght': (-1.0, 1.0)}) + >>> cleanupSpace({'wdth': (0.0, 1.0)}) {'wdth': (0.0, 1.0)} - >>> cleanupSpace({'wdth': (-1.0, 1.0)}, {'wdth': (-1.0, 1.0), 'wght': (-1.0, 1.0)}) + >>> cleanupSpace({'wdth': (-1.0, 1.0)}) {} """ - return {tag: limit for tag, limit in space.items() if limit != defaultSpace[tag]} + return {tag: limit for tag, limit in space.items() if limit != (-1.0, 1.0)} # @@ -312,6 +306,7 @@ def buildFeatureVariations(featureVariationRecords): fv.FeatureVariationRecord = featureVariationRecords return fv + def buildFeatureRecord(featureTag, lookupListIndices): """Build a FeatureRecord.""" fr = ot.FeatureRecord() From 6e9a24b45ba54bec4032fd9234dbaabe24ac5912 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 14 Jun 2018 15:25:09 +0100 Subject: [PATCH 11/11] featureVars: mark module as experimental/subject-to-change --- Lib/fontTools/varLib/featureVars.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/featureVars.py b/Lib/fontTools/varLib/featureVars.py index 306e933c5..60336215c 100644 --- a/Lib/fontTools/varLib/featureVars.py +++ b/Lib/fontTools/varLib/featureVars.py @@ -1,3 +1,8 @@ +"""Module to build FeatureVariation tables: +https://docs.microsoft.com/en-us/typography/opentype/spec/chapter2#featurevariations-table + +NOTE: The API is experimental and subject to change. +""" from __future__ import print_function, absolute_import, division from fontTools.ttLib import newTable @@ -26,7 +31,7 @@ def addFeatureVariations(font, conditionalSubstitutions): """ # Example: - # + # # >>> f = TTFont(srcPath) # >>> condSubst = [ # ... # A list of (Region, Substitution) tuples.