some new features
This commit is contained in:
@ -0,0 +1,695 @@
|
||||
"""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 bit_count
|
||||
from fontTools.ttLib import newTable
|
||||
from fontTools.ttLib.tables import otTables as ot
|
||||
from fontTools.ttLib.ttVisitor import TTVisitor
|
||||
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)
|
||||
|
||||
The `featureTag` parameter takes either a str or a iterable of str (the single str
|
||||
is kept for backwards compatibility), and defines which feature(s) will be
|
||||
associated with the feature variations.
|
||||
Note, if this is "rvrn", then the substitution lookup will be inserted at the
|
||||
beginning of the lookup list so that it is processed before others, otherwise
|
||||
for any other feature tags it will be appended last.
|
||||
"""
|
||||
|
||||
# process first when "rvrn" is the only listed tag
|
||||
featureTags = [featureTag] if isinstance(featureTag, str) else sorted(featureTag)
|
||||
processLast = "rvrn" not in featureTags or len(featureTags) > 1
|
||||
|
||||
_checkSubstitutionGlyphsExist(
|
||||
glyphNames=set(font.getGlyphOrder()),
|
||||
substitutions=conditionalSubstitutions,
|
||||
)
|
||||
|
||||
substitutions = overlayFeatureVariations(conditionalSubstitutions)
|
||||
|
||||
# turn substitution dicts into tuples of tuples, so they are hashable
|
||||
conditionalSubstitutions, allSubstitutions = makeSubstitutionsHashable(
|
||||
substitutions
|
||||
)
|
||||
if "GSUB" not in font:
|
||||
font["GSUB"] = buildGSUB()
|
||||
else:
|
||||
existingTags = _existingVariableFeatures(font["GSUB"].table).intersection(
|
||||
featureTags
|
||||
)
|
||||
if existingTags:
|
||||
raise VarLibError(
|
||||
f"FeatureVariations already exist for feature tag(s): {existingTags}"
|
||||
)
|
||||
|
||||
# setup lookups
|
||||
lookupMap = buildSubstitutionLookups(
|
||||
font["GSUB"].table, allSubstitutions, processLast
|
||||
)
|
||||
|
||||
# addFeatureVariationsRaw takes a list of
|
||||
# ( {condition}, [ lookup indices ] )
|
||||
# so rearrange our lookups to match
|
||||
conditionsAndLookups = []
|
||||
for conditionSet, substitutions in conditionalSubstitutions:
|
||||
conditionsAndLookups.append(
|
||||
(conditionSet, [lookupMap[s] for s in substitutions])
|
||||
)
|
||||
|
||||
addFeatureVariationsRaw(font, font["GSUB"].table, conditionsAndLookups, featureTags)
|
||||
|
||||
|
||||
def _existingVariableFeatures(table):
|
||||
existingFeatureVarsTags = set()
|
||||
if hasattr(table, "FeatureVariations") and table.FeatureVariations is not None:
|
||||
features = table.FeatureList.FeatureRecord
|
||||
for fvr in table.FeatureVariations.FeatureVariationRecord:
|
||||
for ftsr in fvr.FeatureTableSubstitution.SubstitutionRecord:
|
||||
existingFeatureVarsTags.add(features[ftsr.FeatureIndex].FeatureTag)
|
||||
return existingFeatureVarsTags
|
||||
|
||||
|
||||
def _checkSubstitutionGlyphsExist(glyphNames, substitutions):
|
||||
referencedGlyphNames = set()
|
||||
for _, substitution in substitutions:
|
||||
referencedGlyphNames |= substitution.keys()
|
||||
referencedGlyphNames |= set(substitution.values())
|
||||
missing = referencedGlyphNames - glyphNames
|
||||
if missing:
|
||||
raise VarLibValidationError(
|
||||
"Missing glyphs are referenced in conditional substitution rules:"
|
||||
f" {', '.join(missing)}"
|
||||
)
|
||||
|
||||
|
||||
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: -bit_count(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 extrudes 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)
|
||||
extruding = False
|
||||
fullyInside = True
|
||||
for axisTag in top:
|
||||
if axisTag in bot:
|
||||
continue
|
||||
extruding = True
|
||||
fullyInside = False
|
||||
break
|
||||
for axisTag in bot:
|
||||
if axisTag not in top:
|
||||
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 extruding:
|
||||
return intersection, bot
|
||||
extruding = 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, table, conditionalSubstitutions, featureTag="rvrn"):
|
||||
"""Low level implementation of addFeatureVariations that directly
|
||||
models the possibilities of the FeatureVariations table."""
|
||||
|
||||
featureTags = [featureTag] if isinstance(featureTag, str) else sorted(featureTag)
|
||||
processLast = "rvrn" not in featureTags or len(featureTags) > 1
|
||||
|
||||
#
|
||||
# if a <featureTag> feature is not present:
|
||||
# make empty <featureTag> feature
|
||||
# sort features, get <featureTag> feature index
|
||||
# add <featureTag> feature to all scripts
|
||||
# if a <featureTag> feature is present:
|
||||
# reuse <featureTag> feature index
|
||||
# make lookups
|
||||
# add feature variations
|
||||
#
|
||||
if table.Version < 0x00010001:
|
||||
table.Version = 0x00010001 # allow table.FeatureVariations
|
||||
|
||||
varFeatureIndices = set()
|
||||
|
||||
existingTags = {
|
||||
feature.FeatureTag
|
||||
for feature in table.FeatureList.FeatureRecord
|
||||
if feature.FeatureTag in featureTags
|
||||
}
|
||||
|
||||
newTags = set(featureTags) - existingTags
|
||||
if newTags:
|
||||
varFeatures = []
|
||||
for featureTag in sorted(newTags):
|
||||
varFeature = buildFeatureRecord(featureTag, [])
|
||||
table.FeatureList.FeatureRecord.append(varFeature)
|
||||
varFeatures.append(varFeature)
|
||||
table.FeatureList.FeatureCount = len(table.FeatureList.FeatureRecord)
|
||||
|
||||
sortFeatureList(table)
|
||||
|
||||
for varFeature in varFeatures:
|
||||
varFeatureIndex = table.FeatureList.FeatureRecord.index(varFeature)
|
||||
|
||||
for scriptRecord in table.ScriptList.ScriptRecord:
|
||||
if scriptRecord.Script.DefaultLangSys is None:
|
||||
# We need to have a default LangSys to attach variations to.
|
||||
langSys = ot.LangSys()
|
||||
langSys.LookupOrder = None
|
||||
langSys.ReqFeatureIndex = 0xFFFF
|
||||
langSys.FeatureIndex = []
|
||||
langSys.FeatureCount = 0
|
||||
scriptRecord.Script.DefaultLangSys = langSys
|
||||
langSystems = [lsr.LangSys for lsr in scriptRecord.Script.LangSysRecord]
|
||||
for langSys in [scriptRecord.Script.DefaultLangSys] + langSystems:
|
||||
langSys.FeatureIndex.append(varFeatureIndex)
|
||||
langSys.FeatureCount = len(langSys.FeatureIndex)
|
||||
varFeatureIndices.add(varFeatureIndex)
|
||||
|
||||
if existingTags:
|
||||
# indices may have changed if we inserted new features and sorted feature list
|
||||
# so we must do this after the above
|
||||
varFeatureIndices.update(
|
||||
index
|
||||
for index, feature in enumerate(table.FeatureList.FeatureRecord)
|
||||
if feature.FeatureTag in existingTags
|
||||
)
|
||||
|
||||
axisIndices = {
|
||||
axis.axisTag: axisIndex for axisIndex, axis in enumerate(font["fvar"].axes)
|
||||
}
|
||||
|
||||
hasFeatureVariations = (
|
||||
hasattr(table, "FeatureVariations") and table.FeatureVariations is not None
|
||||
)
|
||||
|
||||
featureVariationRecords = []
|
||||
for conditionSet, lookupIndices 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)
|
||||
records = []
|
||||
for varFeatureIndex in sorted(varFeatureIndices):
|
||||
existingLookupIndices = table.FeatureList.FeatureRecord[
|
||||
varFeatureIndex
|
||||
].Feature.LookupListIndex
|
||||
combinedLookupIndices = (
|
||||
existingLookupIndices + lookupIndices
|
||||
if processLast
|
||||
else lookupIndices + existingLookupIndices
|
||||
)
|
||||
|
||||
records.append(
|
||||
buildFeatureTableSubstitutionRecord(
|
||||
varFeatureIndex, combinedLookupIndices
|
||||
)
|
||||
)
|
||||
if hasFeatureVariations and (
|
||||
fvr := findFeatureVariationRecord(table.FeatureVariations, conditionTable)
|
||||
):
|
||||
fvr.FeatureTableSubstitution.SubstitutionRecord.extend(records)
|
||||
fvr.FeatureTableSubstitution.SubstitutionCount = len(
|
||||
fvr.FeatureTableSubstitution.SubstitutionRecord
|
||||
)
|
||||
else:
|
||||
featureVariationRecords.append(
|
||||
buildFeatureVariationRecord(conditionTable, records)
|
||||
)
|
||||
|
||||
if hasFeatureVariations:
|
||||
if table.FeatureVariations.Version != 0x00010000:
|
||||
raise VarLibError(
|
||||
"Unsupported FeatureVariations table version: "
|
||||
f"0x{table.FeatureVariations.Version:08x} (expected 0x00010000)."
|
||||
)
|
||||
table.FeatureVariations.FeatureVariationRecord.extend(featureVariationRecords)
|
||||
table.FeatureVariations.FeatureVariationCount = len(
|
||||
table.FeatureVariations.FeatureVariationRecord
|
||||
)
|
||||
else:
|
||||
table.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 = []
|
||||
srec.Script.LangSysCount = 0
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class ShifterVisitor(TTVisitor):
|
||||
def __init__(self, shift):
|
||||
self.shift = shift
|
||||
|
||||
|
||||
@ShifterVisitor.register_attr(ot.Feature, "LookupListIndex") # GSUB/GPOS
|
||||
def visit(visitor, obj, attr, value):
|
||||
shift = visitor.shift
|
||||
value = [l + shift for l in value]
|
||||
setattr(obj, attr, value)
|
||||
|
||||
|
||||
@ShifterVisitor.register_attr(
|
||||
(ot.SubstLookupRecord, ot.PosLookupRecord), "LookupListIndex"
|
||||
)
|
||||
def visit(visitor, obj, attr, value):
|
||||
setattr(obj, attr, visitor.shift + value)
|
||||
|
||||
|
||||
def buildSubstitutionLookups(gsub, allSubstitutions, processLast=False):
|
||||
"""Build the lookups for the glyph substitutions, return a dict mapping
|
||||
the substitution to lookup indices."""
|
||||
|
||||
# Insert lookups at the beginning of the lookup vector
|
||||
# https://github.com/googlefonts/fontmake/issues/950
|
||||
|
||||
firstIndex = len(gsub.LookupList.Lookup) if processLast else 0
|
||||
lookupMap = {}
|
||||
for i, substitutionMap in enumerate(allSubstitutions):
|
||||
lookupMap[substitutionMap] = firstIndex + i
|
||||
|
||||
if not processLast:
|
||||
# Shift all lookup indices in gsub by len(allSubstitutions)
|
||||
shift = len(allSubstitutions)
|
||||
visitor = ShifterVisitor(shift)
|
||||
visitor.visit(gsub.FeatureList.FeatureRecord)
|
||||
visitor.visit(gsub.LookupList.Lookup)
|
||||
|
||||
for i, subst in enumerate(allSubstitutions):
|
||||
substMap = dict(subst)
|
||||
lookup = buildLookup([buildSingleSubstSubtable(substMap)])
|
||||
if processLast:
|
||||
gsub.LookupList.Lookup.append(lookup)
|
||||
else:
|
||||
gsub.LookupList.Lookup.insert(i, 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
|
||||
fv.FeatureVariationCount = len(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()
|
||||
if len(conditionTable) != 0:
|
||||
fvr.ConditionSet = ot.ConditionSet()
|
||||
fvr.ConditionSet.ConditionTable = conditionTable
|
||||
fvr.ConditionSet.ConditionCount = len(conditionTable)
|
||||
else:
|
||||
fvr.ConditionSet = None
|
||||
fvr.FeatureTableSubstitution = ot.FeatureTableSubstitution()
|
||||
fvr.FeatureTableSubstitution.Version = 0x00010000
|
||||
fvr.FeatureTableSubstitution.SubstitutionRecord = substitutionRecords
|
||||
fvr.FeatureTableSubstitution.SubstitutionCount = len(substitutionRecords)
|
||||
return fvr
|
||||
|
||||
|
||||
def buildFeatureTableSubstitutionRecord(featureIndex, lookupListIndices):
|
||||
"""Build a FeatureTableSubstitutionRecord."""
|
||||
ftsr = ot.FeatureTableSubstitutionRecord()
|
||||
ftsr.FeatureIndex = featureIndex
|
||||
ftsr.Feature = ot.Feature()
|
||||
ftsr.Feature.LookupListIndex = lookupListIndices
|
||||
ftsr.Feature.LookupCount = len(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 findFeatureVariationRecord(featureVariations, conditionTable):
|
||||
"""Find a FeatureVariationRecord that has the same conditionTable."""
|
||||
if featureVariations.Version != 0x00010000:
|
||||
raise VarLibError(
|
||||
"Unsupported FeatureVariations table version: "
|
||||
f"0x{featureVariations.Version:08x} (expected 0x00010000)."
|
||||
)
|
||||
|
||||
for fvr in featureVariations.FeatureVariationRecord:
|
||||
if conditionTable == fvr.ConditionSet.ConditionTable:
|
||||
return fvr
|
||||
|
||||
return None
|
||||
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user