You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
498 lines
18 KiB
498 lines
18 KiB
"""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 fontTools.misc.dictTools import hashdict
|
|
from fontTools.misc.intTools import popCount
|
|
from fontTools.ttLib import newTable
|
|
from fontTools.ttLib.tables import otTables as ot
|
|
from fontTools.otlLib.builder import buildLookup, buildSingleSubstSubtable
|
|
from collections import OrderedDict
|
|
|
|
from .errors import VarLibError, VarLibValidationError
|
|
|
|
|
|
def addFeatureVariations(font, conditionalSubstitutions, featureTag='rvrn'):
|
|
"""Add conditional substitutions to a Variable Font.
|
|
|
|
The `conditionalSubstitutions` argument is a list of (Region, Substitutions)
|
|
tuples.
|
|
|
|
A Region is a list of Boxes. A Box is a dict mapping axisTags to
|
|
(minValue, maxValue) tuples. Irrelevant axes may be omitted and they are
|
|
interpretted as extending to end of axis in each direction. A Box represents
|
|
an orthogonal '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 Boxes in the Region.
|
|
For efficiency, Boxes 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.
|
|
# ... ([{"wdth": (0.5, 1.0)}], {"cent": "cent.rvrn"}),
|
|
# ... ([{"wght": (0.5, 1.0)}], {"dollar": "dollar.rvrn"}),
|
|
# ... ]
|
|
# >>> addFeatureVariations(f, condSubst)
|
|
# >>> f.save(dstPath)
|
|
"""
|
|
|
|
addFeatureVariationsRaw(font,
|
|
overlayFeatureVariations(conditionalSubstitutions),
|
|
featureTag)
|
|
|
|
def overlayFeatureVariations(conditionalSubstitutions):
|
|
"""Compute overlaps between all conditional substitutions.
|
|
|
|
The `conditionalSubstitutions` argument is a list of (Region, Substitutions)
|
|
tuples.
|
|
|
|
A Region is a list of Boxes. A Box is a dict mapping axisTags to
|
|
(minValue, maxValue) tuples. Irrelevant axes may be omitted and they are
|
|
interpretted as extending to end of axis in each direction. A Box represents
|
|
an orthogonal '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 Boxes in the Region.
|
|
For efficiency, Boxes 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.
|
|
|
|
Returns data is in similar but different format. Overlaps of distinct
|
|
substitution Boxes (*not* Regions) are explicitly listed as distinct rules,
|
|
and rules with the same Box merged. The more specific rules appear earlier
|
|
in the resulting list. Moreover, instead of just a dictionary of substitutions,
|
|
a list of dictionaries is returned for substitutions corresponding to each
|
|
unique space, with each dictionary being identical to one of the input
|
|
substitution dictionaries. These dictionaries are not merged to allow data
|
|
sharing when they are converted into font tables.
|
|
|
|
Example:
|
|
>>> condSubst = [
|
|
... # A list of (Region, Substitution) tuples.
|
|
... ([{"wght": (0.5, 1.0)}], {"dollar": "dollar.rvrn"}),
|
|
... ([{"wght": (0.5, 1.0)}], {"dollar": "dollar.rvrn"}),
|
|
... ([{"wdth": (0.5, 1.0)}], {"cent": "cent.rvrn"}),
|
|
... ([{"wght": (0.5, 1.0), "wdth": (-1, 1.0)}], {"dollar": "dollar.rvrn"}),
|
|
... ]
|
|
>>> from pprint import pprint
|
|
>>> pprint(overlayFeatureVariations(condSubst))
|
|
[({'wdth': (0.5, 1.0), 'wght': (0.5, 1.0)},
|
|
[{'dollar': 'dollar.rvrn'}, {'cent': 'cent.rvrn'}]),
|
|
({'wdth': (0.5, 1.0)}, [{'cent': 'cent.rvrn'}]),
|
|
({'wght': (0.5, 1.0)}, [{'dollar': 'dollar.rvrn'}])]
|
|
"""
|
|
|
|
# Merge same-substitutions rules, as this creates fewer number oflookups.
|
|
merged = OrderedDict()
|
|
for value,key in conditionalSubstitutions:
|
|
key = hashdict(key)
|
|
if key in merged:
|
|
merged[key].extend(value)
|
|
else:
|
|
merged[key] = value
|
|
conditionalSubstitutions = [(v,dict(k)) for k,v in merged.items()]
|
|
del merged
|
|
|
|
# Merge same-region rules, as this is cheaper.
|
|
# Also convert boxes to hashdict()
|
|
#
|
|
# Reversing is such that earlier entries win in case of conflicting substitution
|
|
# rules for the same region.
|
|
merged = OrderedDict()
|
|
for key,value in reversed(conditionalSubstitutions):
|
|
key = tuple(sorted((hashdict(cleanupBox(k)) for k in key),
|
|
key=lambda d: tuple(sorted(d.items()))))
|
|
if key in merged:
|
|
merged[key].update(value)
|
|
else:
|
|
merged[key] = dict(value)
|
|
conditionalSubstitutions = list(reversed(merged.items()))
|
|
del merged
|
|
|
|
# Overlay
|
|
#
|
|
# Rank is the bit-set of the index of all contributing layers.
|
|
initMapInit = ((hashdict(),0),) # Initializer representing the entire space
|
|
boxMap = OrderedDict(initMapInit) # Map from Box to Rank
|
|
for i,(currRegion,_) in enumerate(conditionalSubstitutions):
|
|
newMap = OrderedDict(initMapInit)
|
|
currRank = 1<<i
|
|
for box,rank in boxMap.items():
|
|
for currBox in currRegion:
|
|
intersection, remainder = overlayBox(currBox, box)
|
|
if intersection is not None:
|
|
intersection = hashdict(intersection)
|
|
newMap[intersection] = newMap.get(intersection, 0) | rank|currRank
|
|
if remainder is not None:
|
|
remainder = hashdict(remainder)
|
|
newMap[remainder] = newMap.get(remainder, 0) | rank
|
|
boxMap = newMap
|
|
|
|
# Generate output
|
|
items = []
|
|
for box,rank in sorted(boxMap.items(),
|
|
key=(lambda BoxAndRank: -popCount(BoxAndRank[1]))):
|
|
# Skip any box that doesn't have any substitution.
|
|
if rank == 0:
|
|
continue
|
|
substsList = []
|
|
i = 0
|
|
while rank:
|
|
if rank & 1:
|
|
substsList.append(conditionalSubstitutions[i][1])
|
|
rank >>= 1
|
|
i += 1
|
|
items.append((dict(box),substsList))
|
|
return items
|
|
|
|
|
|
#
|
|
# Terminology:
|
|
#
|
|
# A 'Box' is a dict representing an orthogonal "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.
|
|
#
|
|
|
|
def overlayBox(top, bot):
|
|
"""Overlays `top` box on top of `bot` box.
|
|
|
|
Returns two items:
|
|
- Box for intersection of `top` and `bot`, or None if they don't intersect.
|
|
- Box for remainder of `bot`. Remainder box might not be exact (since the
|
|
remainder might not be a simple box), but is inclusive of the exact
|
|
remainder.
|
|
"""
|
|
|
|
# Intersection
|
|
intersection = {}
|
|
intersection.update(top)
|
|
intersection.update(bot)
|
|
for axisTag in set(top) & set(bot):
|
|
min1, max1 = top[axisTag]
|
|
min2, max2 = bot[axisTag]
|
|
minimum = max(min1, min2)
|
|
maximum = min(max1, max2)
|
|
if not minimum < maximum:
|
|
return None, bot # Do not intersect
|
|
intersection[axisTag] = minimum,maximum
|
|
|
|
# Remainder
|
|
#
|
|
# Remainder is empty if bot's each axis range lies within that of intersection.
|
|
#
|
|
# Remainder is shrank if bot's each, except for exactly one, axis range lies
|
|
# within that of intersection, and that one axis, it spills out of the
|
|
# intersection only on one side.
|
|
#
|
|
# Bot is returned in full as remainder otherwise, as true remainder is not
|
|
# representable as a single box.
|
|
|
|
remainder = dict(bot)
|
|
exactlyOne = False
|
|
fullyInside = False
|
|
for axisTag in bot:
|
|
if axisTag not in intersection:
|
|
fullyInside = False
|
|
continue # Axis range lies fully within
|
|
min1, max1 = intersection[axisTag]
|
|
min2, max2 = bot[axisTag]
|
|
if min1 <= min2 and max2 <= max1:
|
|
continue # Axis range lies fully within
|
|
|
|
# Bot's range doesn't fully lie within that of top's for this axis.
|
|
# We know they intersect, so it cannot lie fully without either; so they
|
|
# overlap.
|
|
|
|
# If we have had an overlapping axis before, remainder is not
|
|
# representable as a box, so return full bottom and go home.
|
|
if exactlyOne:
|
|
return intersection, bot
|
|
exactlyOne = True
|
|
fullyInside = False
|
|
|
|
# Otherwise, cut remainder on this axis and continue.
|
|
if min1 <= min2:
|
|
# Right side survives.
|
|
minimum = max(max1, min2)
|
|
maximum = max2
|
|
elif max2 <= max1:
|
|
# Left side survives.
|
|
minimum = min2
|
|
maximum = min(min1, max2)
|
|
else:
|
|
# Remainder leaks out from both sides. Can't cut either.
|
|
return intersection, bot
|
|
|
|
remainder[axisTag] = minimum,maximum
|
|
|
|
if fullyInside:
|
|
# bot is fully within intersection. Remainder is empty.
|
|
return intersection, None
|
|
|
|
return intersection, remainder
|
|
|
|
def cleanupBox(box):
|
|
"""Return a sparse copy of `box`, without redundant (default) values.
|
|
|
|
>>> cleanupBox({})
|
|
{}
|
|
>>> cleanupBox({'wdth': (0.0, 1.0)})
|
|
{'wdth': (0.0, 1.0)}
|
|
>>> cleanupBox({'wdth': (-1.0, 1.0)})
|
|
{}
|
|
|
|
"""
|
|
return {tag: limit for tag, limit in box.items() if limit != (-1.0, 1.0)}
|
|
|
|
|
|
#
|
|
# Low level implementation
|
|
#
|
|
|
|
def addFeatureVariationsRaw(font, conditionalSubstitutions, featureTag='rvrn'):
|
|
"""Low level implementation of addFeatureVariations that directly
|
|
models the possibilities of the FeatureVariations table."""
|
|
|
|
#
|
|
# if there is no <featureTag> feature:
|
|
# make empty <featureTag> feature
|
|
# sort features, get <featureTag> feature index
|
|
# add <featureTag> 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
|
|
|
|
varFeatureIndices = []
|
|
for index, feature in enumerate(gsub.FeatureList.FeatureRecord):
|
|
if feature.FeatureTag == featureTag:
|
|
varFeatureIndices.append(index)
|
|
|
|
if not varFeatureIndices:
|
|
varFeature = buildFeatureRecord(featureTag, [])
|
|
gsub.FeatureList.FeatureRecord.append(varFeature)
|
|
gsub.FeatureList.FeatureCount = len(gsub.FeatureList.FeatureRecord)
|
|
|
|
sortFeatureList(gsub)
|
|
varFeatureIndex = gsub.FeatureList.FeatureRecord.index(varFeature)
|
|
|
|
for scriptRecord in gsub.ScriptList.ScriptRecord:
|
|
if scriptRecord.Script.DefaultLangSys is None:
|
|
raise VarLibError(
|
|
"Feature variations require that the script "
|
|
f"'{scriptRecord.ScriptTag}' defines a default language system."
|
|
)
|
|
langSystems = [lsr.LangSys for lsr in scriptRecord.Script.LangSysRecord]
|
|
for langSys in [scriptRecord.Script.DefaultLangSys] + langSystems:
|
|
langSys.FeatureIndex.append(varFeatureIndex)
|
|
|
|
varFeatureIndices = [varFeatureIndex]
|
|
|
|
# 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()):
|
|
if minValue > maxValue:
|
|
raise VarLibValidationError(
|
|
"A condition set has a minimum value above the maximum value."
|
|
)
|
|
ct = buildConditionTable(axisIndices[axisTag], minValue, maxValue)
|
|
conditionTable.append(ct)
|
|
|
|
lookupIndices = [lookupMap[subst] for subst in substitutions]
|
|
records = []
|
|
for varFeatureIndex in varFeatureIndices:
|
|
existingLookupIndices = gsub.FeatureList.FeatureRecord[varFeatureIndex].Feature.LookupListIndex
|
|
records.append(buildFeatureTableSubstitutionRecord(varFeatureIndex, existingLookupIndices + lookupIndices))
|
|
featureVariationRecords.append(buildFeatureVariationRecord(conditionTable, records))
|
|
|
|
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 = []
|
|
srec.Script.DefaultLangSys = langrec.LangSys
|
|
|
|
gsub.ScriptList.ScriptRecord.append(srec)
|
|
gsub.ScriptList.ScriptCount = 1
|
|
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
|
|
gsub.LookupList.LookupCount = len(gsub.LookupList.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
|
|
fr.Feature.populateDefaults()
|
|
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, sys
|
|
sys.exit(doctest.testmod().failed)
|