fonttools/Lib/fontTools/varLib/featureVars.py

406 lines
14 KiB
Python

"""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))
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
>>> 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 = 0x00010000
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()