Merge pull request #1240 from fonttools/add-feature-variations
[varLib] Add Feature Variations
This commit is contained in:
commit
03c57a0f5f
@ -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.
|
||||
"""
|
||||
@ -175,7 +175,7 @@ def _add_stat(font, axes):
|
||||
# https://github.com/LettError/designSpaceDocument/issues/8
|
||||
|
||||
if "STAT" in font:
|
||||
return
|
||||
return
|
||||
|
||||
fvarTable = font['fvar']
|
||||
|
||||
@ -695,8 +695,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 = {}
|
||||
@ -705,7 +705,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))
|
||||
|
||||
|
||||
|
392
Lib/fontTools/varLib/featureVars.py
Normal file
392
Lib/fontTools/varLib/featureVars.py
Normal file
@ -0,0 +1,392 @@
|
||||
"""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
|
||||
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.
|
||||
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:
|
||||
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()
|
Loading…
x
Reference in New Issue
Block a user