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
399 lines
14 KiB
Python
399 lines
14 KiB
Python
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
|
|
|
|
|
|
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 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.
|
|
"""
|
|
|
|
# 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 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, defaultSpace)
|
|
if space:
|
|
space = normalizeSpace(space, axisMap)
|
|
explodedConditionalSubstitutions.append((space, lookups))
|
|
|
|
addFeatureVariationsRaw(font, 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
|
|
|
|
"""
|
|
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
|
|
|
|
"""
|
|
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()
|