"""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 from fontTools.ttLib.tables import otTables as ot from fontTools.otlLib.builder import buildLookup, buildSingleSubstSubtable from collections import OrderedDict import itertools 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 normalized coordinates. 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) # 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. # Merge duplicate region rules before combinatorial explosion. merged = OrderedDict() for key,value in conditionalSubstitutions: key = tuple(sorted(tuple(sorted(k.items())) for k in key)) if key in merged: merged[key].update(value) else: merged[key] = value conditionalSubstitutions = [([dict(k) for k in key],value) for key,value in merged.items()] explodedConditionalSubstitutions = [] for combination in iterAllCombinations(len(conditionalSubstitutions)): regions = [] lookups = [] for index in combination: 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) if space: explodedConditionalSubstitutions.append((space, lookups)) if font is not None: addFeatureVariationsRaw(font, explodedConditionalSubstitutions) return explodedConditionalSubstitutions 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,)] """ indices = range(numRules) for length in range(numRules, 0, -1): for combinations in itertools.combinations(indices, length): yield combinations # # 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)}], []) [] >>> 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 >>> intersectRegions( ... [{'wdth': (0.0, 1.0), 'wght': (-0.5, 0.5)}], ... [{'wdth': (-1.0, 0.0), 'wght': (-1.0, 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)} >>> 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 >>> intersectSpaces({'wdth': (-0.5, 0)}, {'wdth': (0.1, 0.5)}) """ 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): """Return a sparse copy of `space`, without redundant (default) values. >>> cleanupSpace({}) {} >>> cleanupSpace({'wdth': (0.0, 1.0)}) {'wdth': (0.0, 1.0)} >>> cleanupSpace({'wdth': (-1.0, 1.0)}) {} """ return {tag: limit for tag, limit in space.items() if limit != (-1.0, 1.0)} # # 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: langSystems = [lsr.LangSys for lsr in scriptRecord.Script.LangSysRecord] for langSys in [scriptRecord.Script.DefaultLangSys] + langSystems: 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()