instancer: implement restricting axis ranges (L3)
Added method to limitTupleVariationAxisRanges which takes a map of axis tags to (min, max) ranges and drops entire deltasets when outside of the new limits, or scales the ones that are within range. Modified _TupleVarStoreAdapter to account for the fact that, when limiting axes, existing regions can be modifed rathern than simply dropped (see rebuildRegions). Implemented limiting axis ranges for fvar, FeatureVariations, and avar. Note how we pass user-scale coordinates to instantiateAvar, because we need both the default normalized coordinates and the ones mapped forward (by the very same avar table that we are instancing). STAT table support will follow in a separate commit.
This commit is contained in:
parent
550711e106
commit
0b746bc38d
@ -65,7 +65,12 @@ are supported, but support for CFF2 variable fonts will be added soon.
|
||||
The discussion and implementation of these features are tracked at
|
||||
https://github.com/fonttools/fonttools/issues/1537
|
||||
"""
|
||||
from fontTools.misc.fixedTools import floatToFixedToFloat, otRound
|
||||
from fontTools.misc.fixedTools import (
|
||||
floatToFixedToFloat,
|
||||
strToFixedToFloat,
|
||||
otRound,
|
||||
MAX_F2DOT14,
|
||||
)
|
||||
from fontTools.varLib.models import supportScalar, normalizeValue, piecewiseLinearMap
|
||||
from fontTools.ttLib import TTFont
|
||||
from fontTools.ttLib.tables.TupleVariation import TupleVariation
|
||||
@ -90,12 +95,44 @@ import re
|
||||
log = logging.getLogger("fontTools.varLib.instancer")
|
||||
|
||||
|
||||
def instantiateTupleVariationStore(variations, location, origCoords=None, endPts=None):
|
||||
"""Instantiate TupleVariation list at the given location.
|
||||
class AxisRange(collections.namedtuple("AxisRange", "minimum maximum")):
|
||||
def __new__(cls, *args, **kwargs):
|
||||
self = super().__new__(cls, *args, **kwargs)
|
||||
if self.minimum > self.maximum:
|
||||
raise ValueError(
|
||||
f"Range minimum ({self.minimum:g}) must be <= maximum ({self.maximum:g})"
|
||||
)
|
||||
return self
|
||||
|
||||
def __repr__(self):
|
||||
return f"{type(self).__name__}({self.minimum:g}, {self.maximum:g})"
|
||||
|
||||
|
||||
class NormalizedAxisRange(AxisRange):
|
||||
def __new__(cls, *args, **kwargs):
|
||||
self = super().__new__(cls, *args, **kwargs)
|
||||
if self.minimum < -1.0 or self.maximum > 1.0:
|
||||
raise ValueError("Axis range values must be normalized to -1..+1 range")
|
||||
if self.minimum > 0:
|
||||
raise ValueError(f"Expected axis range minimum <= 0; got {self.minimum}")
|
||||
if self.maximum < 0:
|
||||
raise ValueError(f"Expected axis range maximum >= 0; got {self.maximum}")
|
||||
return self
|
||||
|
||||
|
||||
def instantiateTupleVariationStore(
|
||||
variations, axisLimits, origCoords=None, endPts=None
|
||||
):
|
||||
"""Instantiate TupleVariation list at the given location, or limit axes' min/max.
|
||||
|
||||
The 'variations' list of TupleVariation objects is modified in-place.
|
||||
The input location can describe either a full instance (all the axes are assigned an
|
||||
explicit coordinate) or partial (some of the axes are omitted).
|
||||
The 'axisLimits' (dict) maps axis tags (str) to either a single coordinate along the
|
||||
axis (float), or to minimum/maximum coordinates (NormalizedAxisRange).
|
||||
|
||||
A 'full' instance (i.e. static font) is produced when all the axes are pinned to
|
||||
single coordinates; a 'partial' instance (i.e. a less variable font) is produced
|
||||
when some of the axes are omitted, or restricted with a new range.
|
||||
|
||||
Tuples that do not participate are kept as they are. Those that have 0 influence
|
||||
at the given location are removed from the variation store.
|
||||
Those that are fully instantiated (i.e. all their axes are being pinned) are also
|
||||
@ -107,7 +144,8 @@ def instantiateTupleVariationStore(variations, location, origCoords=None, endPts
|
||||
|
||||
Args:
|
||||
variations: List[TupleVariation] from either 'gvar' or 'cvar'.
|
||||
location: Dict[str, float]: axes coordinates for the full or partial instance.
|
||||
axisLimits: Dict[str, Union[float, NormalizedAxisRange]]: axes' coordinates for
|
||||
the full or partial instance, or ranges for restricting an axis' min/max.
|
||||
origCoords: GlyphCoordinates: default instance's coordinates for computing 'gvar'
|
||||
inferred points (cf. table__g_l_y_f.getCoordinatesAndControls).
|
||||
endPts: List[int]: indices of contour end points, for inferring 'gvar' deltas.
|
||||
@ -115,7 +153,44 @@ def instantiateTupleVariationStore(variations, location, origCoords=None, endPts
|
||||
Returns:
|
||||
List[float]: the overall delta adjustment after applicable deltas were summed.
|
||||
"""
|
||||
newVariations = collections.OrderedDict()
|
||||
pinnedLocation, axisRanges = splitAxisLocationAndRanges(
|
||||
axisLimits, rangeType=NormalizedAxisRange
|
||||
)
|
||||
|
||||
if pinnedLocation:
|
||||
newVariations = pinTupleVariationAxes(variations, pinnedLocation)
|
||||
else:
|
||||
newVariations = variations
|
||||
|
||||
if axisRanges:
|
||||
newVariations = limitTupleVariationAxisRanges(newVariations, axisRanges)
|
||||
|
||||
mergedVariations = collections.OrderedDict()
|
||||
for var in newVariations:
|
||||
# compute inferred deltas only for gvar ('origCoords' is None for cvar)
|
||||
if origCoords is not None:
|
||||
var.calcInferredDeltas(origCoords, endPts)
|
||||
|
||||
# merge TupleVariations with overlapping "tents"
|
||||
axes = frozenset(var.axes.items())
|
||||
if axes in mergedVariations:
|
||||
mergedVariations[axes] += var
|
||||
else:
|
||||
mergedVariations[axes] = var
|
||||
|
||||
# drop TupleVariation if all axes have been pinned (var.axes.items() is empty);
|
||||
# its deltas will be added to the default instance's coordinates
|
||||
defaultVar = mergedVariations.pop(frozenset(), None)
|
||||
|
||||
for var in mergedVariations.values():
|
||||
var.roundDeltas()
|
||||
variations[:] = list(mergedVariations.values())
|
||||
|
||||
return defaultVar.coordinates if defaultVar is not None else []
|
||||
|
||||
|
||||
def pinTupleVariationAxes(variations, location):
|
||||
newVariations = []
|
||||
for var in variations:
|
||||
# Compute the scalar support of the axes to be pinned at the desired location,
|
||||
# excluding any axes that we are not pinning.
|
||||
@ -127,28 +202,109 @@ def instantiateTupleVariationStore(variations, location, origCoords=None, endPts
|
||||
# no influence, drop the TupleVariation
|
||||
continue
|
||||
|
||||
# compute inferred deltas only for gvar ('origCoords' is None for cvar)
|
||||
if origCoords is not None:
|
||||
var.calcInferredDeltas(origCoords, endPts)
|
||||
|
||||
var.scaleDeltas(scalar)
|
||||
newVariations.append(var)
|
||||
return newVariations
|
||||
|
||||
# merge TupleVariations with overlapping "tents"
|
||||
axes = tuple(var.axes.items())
|
||||
if axes in newVariations:
|
||||
newVariations[axes] += var
|
||||
|
||||
def limitTupleVariationAxisRanges(variations, axisRanges):
|
||||
for axisTag, axisRange in sorted(axisRanges.items()):
|
||||
newVariations = []
|
||||
for var in variations:
|
||||
newVariations.extend(limitTupleVariationAxisRange(var, axisTag, axisRange))
|
||||
variations = newVariations
|
||||
return variations
|
||||
|
||||
|
||||
def _negate(*values):
|
||||
yield from (-1 * v for v in values)
|
||||
|
||||
|
||||
def limitTupleVariationAxisRange(var, axisTag, axisRange):
|
||||
if not isinstance(axisRange, NormalizedAxisRange):
|
||||
axisRange = NormalizedAxisRange(*axisRange)
|
||||
|
||||
# skip when current axis is missing (i.e. doesn't participate), or when the
|
||||
# 'tent' isn't fully on either the negative or positive side
|
||||
lower, peak, upper = var.axes.get(axisTag, (-1, 0, 1))
|
||||
if peak == 0 or lower > peak or peak > upper or (lower < 0 and upper > 0):
|
||||
return [var]
|
||||
|
||||
negative = lower < 0
|
||||
if negative:
|
||||
if axisRange.minimum == -1.0:
|
||||
return [var]
|
||||
elif axisRange.minimum == 0.0:
|
||||
return []
|
||||
else:
|
||||
newVariations[axes] = var
|
||||
if axisRange.maximum == 1.0:
|
||||
return [var]
|
||||
elif axisRange.maximum == 0.0:
|
||||
return []
|
||||
|
||||
# drop TupleVariation if all axes have been pinned (var.axes.items() is empty);
|
||||
# its deltas will be added to the default instance's coordinates
|
||||
defaultVar = newVariations.pop(tuple(), None)
|
||||
limit = axisRange.minimum if negative else axisRange.maximum
|
||||
|
||||
for var in newVariations.values():
|
||||
var.roundDeltas()
|
||||
variations[:] = list(newVariations.values())
|
||||
# Rebase axis bounds onto the new limit, which then becomes the new -1.0 or +1.0.
|
||||
# The results are always positive, because both dividend and divisor are either
|
||||
# all positive or all negative.
|
||||
newLower = lower / limit
|
||||
newPeak = peak / limit
|
||||
newUpper = upper / limit
|
||||
# for negative TupleVariation, swap lower and upper to simplify procedure
|
||||
if negative:
|
||||
newLower, newUpper = newUpper, newLower
|
||||
|
||||
return defaultVar.coordinates if defaultVar is not None else []
|
||||
# special case when innermost bound == peak == limit
|
||||
if newLower == newPeak == 1.0:
|
||||
var.axes[axisTag] = (-1.0, -1.0, -1.0) if negative else (1.0, 1.0, 1.0)
|
||||
return [var]
|
||||
|
||||
# case 1: the whole deltaset falls outside the new limit; we can drop it
|
||||
elif newLower >= 1.0:
|
||||
return []
|
||||
|
||||
# case 2: only the peak and outermost bound fall outside the new limit;
|
||||
# we keep the deltaset, update peak and outermost bound and and scale deltas
|
||||
# by the scalar value for the restricted axis at the new limit.
|
||||
elif newPeak >= 1.0:
|
||||
scalar = supportScalar({axisTag: limit}, {axisTag: (lower, peak, upper)})
|
||||
var.scaleDeltas(scalar)
|
||||
newPeak = 1.0
|
||||
newUpper = 1.0
|
||||
if negative:
|
||||
newLower, newPeak, newUpper = _negate(newUpper, newPeak, newLower)
|
||||
var.axes[axisTag] = (newLower, newPeak, newUpper)
|
||||
return [var]
|
||||
|
||||
# case 3: peak falls inside but outermost limit still fits within F2Dot14 bounds;
|
||||
# we keep deltas as is and only scale the axes bounds. Deltas beyond -1.0
|
||||
# or +1.0 will never be applied as implementations must clap to that range.
|
||||
elif newUpper <= 2.0:
|
||||
if negative:
|
||||
newLower, newPeak, newUpper = _negate(newUpper, newPeak, newLower)
|
||||
elif MAX_F2DOT14 < newUpper <= 2.0:
|
||||
# we clamp +2.0 to the max F2Dot14 (~1.99994) for convenience
|
||||
newUpper = MAX_F2DOT14
|
||||
var.axes[axisTag] = (newLower, newPeak, newUpper)
|
||||
return [var]
|
||||
|
||||
# case 4: new limit doesn't fit, we need to chop the tent into two triangles,
|
||||
# with an additional tent with scaled-down deltas that peaks as the original
|
||||
# one tapers down. NOTE: This increases the file size!
|
||||
else:
|
||||
newVar = TupleVariation(var.axes, var.coordinates)
|
||||
if negative:
|
||||
var.axes[axisTag] = (-2.0, -1 * newPeak, -1 * newLower)
|
||||
newVar.axes[axisTag] = (-1.0, -1.0, -1 * newPeak)
|
||||
else:
|
||||
var.axes[axisTag] = (newLower, newPeak, MAX_F2DOT14)
|
||||
newVar.axes[axisTag] = (newPeak, 1.0, 1.0)
|
||||
# TODO: document optimization
|
||||
scalar1 = supportScalar({axisTag: limit}, {axisTag: (lower, peak, upper)})
|
||||
scalar2 = 1 / (2 - newPeak)
|
||||
newVar.scaleDeltas(scalar1 - scalar2)
|
||||
|
||||
return [var, newVar]
|
||||
|
||||
|
||||
def instantiateGvarGlyph(varfont, glyphname, location, optimize=True):
|
||||
@ -277,12 +433,14 @@ def _remapVarIdxMap(table, attrName, varIndexMapping, glyphOrder):
|
||||
|
||||
|
||||
# TODO(anthrotype) Add support for HVAR/VVAR in CFF2
|
||||
def _instantiateVHVAR(varfont, location, tableFields):
|
||||
def _instantiateVHVAR(varfont, axisLimits, tableFields):
|
||||
tableTag = tableFields.tableTag
|
||||
fvarAxes = varfont["fvar"].axes
|
||||
# Deltas from gvar table have already been applied to the hmtx/vmtx. For full
|
||||
# instances (i.e. all axes pinned), we can simply drop HVAR/VVAR and return
|
||||
if set(location).issuperset(axis.axisTag for axis in fvarAxes):
|
||||
if set(
|
||||
axisTag for axisTag, value in axisLimits.items() if not isinstance(value, tuple)
|
||||
).issuperset(axis.axisTag for axis in fvarAxes):
|
||||
log.info("Dropping %s table", tableTag)
|
||||
del varfont[tableTag]
|
||||
return
|
||||
@ -291,7 +449,7 @@ def _instantiateVHVAR(varfont, location, tableFields):
|
||||
vhvar = varfont[tableTag].table
|
||||
varStore = vhvar.VarStore
|
||||
# since deltas were already applied, the return value here is ignored
|
||||
instantiateItemVariationStore(varStore, fvarAxes, location)
|
||||
instantiateItemVariationStore(varStore, fvarAxes, axisLimits)
|
||||
|
||||
if varStore.VarRegionList.Region:
|
||||
# Only re-optimize VarStore if the HVAR/VVAR already uses indirect AdvWidthMap
|
||||
@ -345,30 +503,47 @@ class _TupleVarStoreAdapter(object):
|
||||
itemCounts.append(varData.ItemCount)
|
||||
return cls(regions, axisOrder, tupleVarData, itemCounts)
|
||||
|
||||
def dropAxes(self, axes):
|
||||
prunedRegions = (
|
||||
frozenset(
|
||||
(axisTag, support)
|
||||
for axisTag, support in region.items()
|
||||
if axisTag not in axes
|
||||
def rebuildRegions(self):
|
||||
# Collect the set of all unique region axes from the current TupleVariations.
|
||||
# We use an OrderedDict to de-duplicate regions while keeping the order.
|
||||
uniqueRegions = collections.OrderedDict.fromkeys(
|
||||
(
|
||||
frozenset(var.axes.items())
|
||||
for variations in self.tupleVarData
|
||||
for var in variations
|
||||
)
|
||||
for region in self.regions
|
||||
)
|
||||
# dedup regions while keeping original order
|
||||
uniqueRegions = collections.OrderedDict.fromkeys(prunedRegions)
|
||||
self.regions = [dict(items) for items in uniqueRegions if items]
|
||||
self.axisOrder = [axisTag for axisTag in self.axisOrder if axisTag not in axes]
|
||||
# Maintain the original order for the regions that pre-existed, appending
|
||||
# the new regions at the end of the region list.
|
||||
newRegions = []
|
||||
for region in self.regions:
|
||||
regionAxes = frozenset(region.items())
|
||||
if regionAxes in uniqueRegions:
|
||||
newRegions.append(region)
|
||||
del uniqueRegions[regionAxes]
|
||||
if uniqueRegions:
|
||||
newRegions.extend(dict(region) for region in uniqueRegions)
|
||||
self.regions = newRegions
|
||||
|
||||
def instantiate(self, location):
|
||||
def instantiate(self, axisLimits):
|
||||
defaultDeltaArray = []
|
||||
for variations, itemCount in zip(self.tupleVarData, self.itemCounts):
|
||||
defaultDeltas = instantiateTupleVariationStore(variations, location)
|
||||
defaultDeltas = instantiateTupleVariationStore(variations, axisLimits)
|
||||
if not defaultDeltas:
|
||||
defaultDeltas = [0] * itemCount
|
||||
defaultDeltaArray.append(defaultDeltas)
|
||||
|
||||
# remove pinned axes from all the regions
|
||||
self.dropAxes(location.keys())
|
||||
# rebuild regions whose axes were dropped or limited
|
||||
self.rebuildRegions()
|
||||
|
||||
pinnedAxes = {
|
||||
axisTag
|
||||
for axisTag, value in axisLimits.items()
|
||||
if not isinstance(value, tuple)
|
||||
}
|
||||
self.axisOrder = [
|
||||
axisTag for axisTag in self.axisOrder if axisTag not in pinnedAxes
|
||||
]
|
||||
|
||||
return defaultDeltaArray
|
||||
|
||||
@ -396,7 +571,7 @@ class _TupleVarStoreAdapter(object):
|
||||
return itemVarStore
|
||||
|
||||
|
||||
def instantiateItemVariationStore(itemVarStore, fvarAxes, location):
|
||||
def instantiateItemVariationStore(itemVarStore, fvarAxes, axisLimits):
|
||||
""" Compute deltas at partial location, and update varStore in-place.
|
||||
|
||||
Remove regions in which all axes were instanced, and scale the deltas of
|
||||
@ -417,7 +592,7 @@ def instantiateItemVariationStore(itemVarStore, fvarAxes, location):
|
||||
keyed by VariationIndex compound values: i.e. (outer << 16) + inner.
|
||||
"""
|
||||
tupleVarStore = _TupleVarStoreAdapter.fromItemVarStore(itemVarStore, fvarAxes)
|
||||
defaultDeltaArray = tupleVarStore.instantiate(location)
|
||||
defaultDeltaArray = tupleVarStore.instantiate(axisLimits)
|
||||
newItemVarStore = tupleVarStore.asItemVarStore()
|
||||
|
||||
itemVarStore.VarRegionList = newItemVarStore.VarRegionList
|
||||
@ -491,7 +666,7 @@ def instantiateOTL(varfont, location):
|
||||
del varfont["GDEF"]
|
||||
|
||||
|
||||
def instantiateFeatureVariations(varfont, location):
|
||||
def instantiateFeatureVariations(varfont, axisLimits):
|
||||
for tableTag in ("GPOS", "GSUB"):
|
||||
if tableTag not in varfont or not hasattr(
|
||||
varfont[tableTag].table, "FeatureVariations"
|
||||
@ -499,7 +674,7 @@ def instantiateFeatureVariations(varfont, location):
|
||||
continue
|
||||
log.info("Instantiating FeatureVariations of %s table", tableTag)
|
||||
_instantiateFeatureVariations(
|
||||
varfont[tableTag].table, varfont["fvar"].axes, location
|
||||
varfont[tableTag].table, varfont["fvar"].axes, axisLimits
|
||||
)
|
||||
# remove unreferenced lookups
|
||||
varfont[tableTag].prune_lookups()
|
||||
@ -527,10 +702,48 @@ def _featureVariationRecordIsUnique(rec, seen):
|
||||
return True
|
||||
|
||||
|
||||
def _limitFeatureVariationConditionRange(condition, axisRange):
|
||||
minValue = condition.FilterRangeMinValue
|
||||
maxValue = condition.FilterRangeMaxValue
|
||||
|
||||
if (
|
||||
minValue > maxValue
|
||||
or minValue > axisRange.maximum
|
||||
or maxValue < axisRange.minimum
|
||||
):
|
||||
# condition invalid or out of range
|
||||
return
|
||||
|
||||
values = [minValue, maxValue]
|
||||
for i, value in enumerate(values):
|
||||
if value < 0:
|
||||
if axisRange.minimum == 0:
|
||||
newValue = 0
|
||||
else:
|
||||
newValue = value / abs(axisRange.minimum)
|
||||
if newValue <= -1.0:
|
||||
newValue = -1.0
|
||||
elif value > 0:
|
||||
if axisRange.maximum == 0:
|
||||
newValue = 0
|
||||
else:
|
||||
newValue = value / axisRange.maximum
|
||||
if newValue >= 1.0:
|
||||
newValue = 1.0
|
||||
else:
|
||||
newValue = 0
|
||||
values[i] = newValue
|
||||
|
||||
# TODO(anthrotype): Is (0,0) condition supposed to be applied ever? Ask Behdad
|
||||
# if not any(values):
|
||||
# return
|
||||
|
||||
return AxisRange(*values)
|
||||
|
||||
|
||||
def _instantiateFeatureVariationRecord(
|
||||
record, recIdx, location, fvarAxes, axisIndexMap
|
||||
):
|
||||
shouldKeep = False
|
||||
applies = True
|
||||
newConditions = []
|
||||
for i, condition in enumerate(record.ConditionSet.ConditionTable):
|
||||
@ -562,11 +775,48 @@ def _instantiateFeatureVariationRecord(
|
||||
if newConditions:
|
||||
record.ConditionSet.ConditionTable = newConditions
|
||||
shouldKeep = True
|
||||
else:
|
||||
shouldKeep = False
|
||||
|
||||
return applies, shouldKeep
|
||||
|
||||
|
||||
def _instantiateFeatureVariations(table, fvarAxes, location):
|
||||
def _limitFeatureVariationRecord(record, axisRanges, fvarAxes):
|
||||
newConditions = []
|
||||
for i, condition in enumerate(record.ConditionSet.ConditionTable):
|
||||
if condition.Format == 1:
|
||||
axisIdx = condition.AxisIndex
|
||||
axisTag = fvarAxes[axisIdx].axisTag
|
||||
if axisTag in axisRanges:
|
||||
axisRange = axisRanges[axisTag]
|
||||
newRange = _limitFeatureVariationConditionRange(condition, axisRange)
|
||||
if newRange:
|
||||
# keep condition with updated limits and remapped axis index
|
||||
condition.FilterRangeMinValue = newRange.minimum
|
||||
condition.FilterRangeMaxValue = newRange.maximum
|
||||
newConditions.append(condition)
|
||||
else:
|
||||
# condition out of range, remove entire record
|
||||
newConditions = None
|
||||
break
|
||||
else:
|
||||
newConditions.append(condition)
|
||||
else:
|
||||
newConditions.append(condition)
|
||||
|
||||
if newConditions:
|
||||
record.ConditionSet.ConditionTable = newConditions
|
||||
shouldKeep = True
|
||||
else:
|
||||
shouldKeep = False
|
||||
|
||||
return shouldKeep
|
||||
|
||||
|
||||
def _instantiateFeatureVariations(table, fvarAxes, axisLimits):
|
||||
location, axisRanges = splitAxisLocationAndRanges(
|
||||
axisLimits, rangeType=NormalizedAxisRange
|
||||
)
|
||||
pinnedAxes = set(location.keys())
|
||||
axisOrder = [axis.axisTag for axis in fvarAxes if axis.axisTag not in pinnedAxes]
|
||||
axisIndexMap = {axisTag: axisOrder.index(axisTag) for axisTag in axisOrder}
|
||||
@ -580,7 +830,9 @@ def _instantiateFeatureVariations(table, fvarAxes, location):
|
||||
record, i, location, fvarAxes, axisIndexMap
|
||||
)
|
||||
if shouldKeep:
|
||||
if _featureVariationRecordIsUnique(record, uniqueRecords):
|
||||
shouldKeep = _limitFeatureVariationRecord(record, axisRanges, fvarAxes)
|
||||
|
||||
if shouldKeep and _featureVariationRecordIsUnique(record, uniqueRecords):
|
||||
newRecords.append(record)
|
||||
|
||||
if applies and not featureVariationApplied:
|
||||
@ -597,23 +849,101 @@ def _instantiateFeatureVariations(table, fvarAxes, location):
|
||||
del table.FeatureVariations
|
||||
|
||||
|
||||
def instantiateAvar(varfont, location):
|
||||
def _isValidAvarSegmentMap(axisTag, segmentMap):
|
||||
if not segmentMap:
|
||||
return True
|
||||
if not {(-1.0, -1.0), (0, 0), (1.0, 1.0)}.issubset(segmentMap.items()):
|
||||
log.warning(
|
||||
f"Invalid avar SegmentMap record for axis '{axisTag}': does not "
|
||||
"include all required value maps {-1.0: -1.0, 0: 0, 1.0: 1.0}"
|
||||
)
|
||||
return False
|
||||
previousValue = None
|
||||
for fromCoord, toCoord in sorted(segmentMap.items()):
|
||||
if previousValue is not None and previousValue > toCoord:
|
||||
log.warning(
|
||||
f"Invalid avar AxisValueMap({fromCoord}, {toCoord}) record "
|
||||
f"for axis '{axisTag}': the toCoordinate value must be >= to "
|
||||
f"the toCoordinate value of the preceding record ({previousValue})."
|
||||
)
|
||||
return False
|
||||
previousValue = toCoord
|
||||
return True
|
||||
|
||||
|
||||
def instantiateAvar(varfont, axisLimits):
|
||||
location, axisRanges = splitAxisLocationAndRanges(axisLimits)
|
||||
|
||||
segments = varfont["avar"].segments
|
||||
|
||||
# drop table if we instantiate all the axes
|
||||
if set(location).issuperset(segments):
|
||||
pinnedAxes = set(location.keys())
|
||||
if pinnedAxes.issuperset(segments):
|
||||
log.info("Dropping avar table")
|
||||
del varfont["avar"]
|
||||
return
|
||||
|
||||
log.info("Instantiating avar table")
|
||||
for axis in location:
|
||||
for axis in pinnedAxes:
|
||||
if axis in segments:
|
||||
del segments[axis]
|
||||
|
||||
normalizedRanges = normalizeAxisLimits(varfont, axisRanges, usingAvar=False)
|
||||
newSegments = {}
|
||||
for axisTag, mapping in segments.items():
|
||||
if not _isValidAvarSegmentMap(axisTag, mapping):
|
||||
continue
|
||||
if mapping and axisTag in normalizedRanges:
|
||||
axisRange = normalizedRanges[axisTag]
|
||||
mappedMin = floatToFixedToFloat(
|
||||
piecewiseLinearMap(axisRange.minimum, mapping), 14
|
||||
)
|
||||
mappedMax = floatToFixedToFloat(
|
||||
piecewiseLinearMap(axisRange.maximum, mapping), 14
|
||||
)
|
||||
newMapping = {}
|
||||
for key, value in mapping.items():
|
||||
if key < 0:
|
||||
if axisRange.minimum == 0 or key < axisRange.minimum:
|
||||
continue
|
||||
else:
|
||||
key /= abs(axisRange.minimum)
|
||||
elif key > 0:
|
||||
if axisRange.maximum == 0 or key > axisRange.maximum:
|
||||
continue
|
||||
else:
|
||||
key /= axisRange.maximum
|
||||
if value < 0:
|
||||
assert mappedMin != 0
|
||||
assert value >= mappedMin
|
||||
value /= abs(mappedMin)
|
||||
elif value > 0:
|
||||
assert mappedMax != 0
|
||||
assert value <= mappedMax
|
||||
value /= mappedMax
|
||||
key = floatToFixedToFloat(key, 14)
|
||||
value = floatToFixedToFloat(value, 14)
|
||||
newMapping[key] = value
|
||||
newMapping.update({-1.0: -1.0, 1.0: 1.0})
|
||||
newSegments[axisTag] = newMapping
|
||||
else:
|
||||
newSegments[axisTag] = mapping
|
||||
varfont["avar"].segments = newSegments
|
||||
|
||||
def instantiateFvar(varfont, location):
|
||||
# 'location' dict must contain user-space (non-normalized) coordinates
|
||||
|
||||
def isInstanceWithinAxisRanges(location, axisRanges):
|
||||
for axisTag, coord in location.items():
|
||||
if axisTag in axisRanges:
|
||||
axisRange = axisRanges[axisTag]
|
||||
if coord < axisRange.minimum or coord > axisRange.maximum:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def instantiateFvar(varfont, axisLimits):
|
||||
# 'axisLimits' dict must contain user-space (non-normalized) coordinates
|
||||
|
||||
location, axisRanges = splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange)
|
||||
|
||||
fvar = varfont["fvar"]
|
||||
|
||||
@ -625,20 +955,35 @@ def instantiateFvar(varfont, location):
|
||||
|
||||
log.info("Instantiating fvar table")
|
||||
|
||||
fvar.axes = [axis for axis in fvar.axes if axis.axisTag not in location]
|
||||
axes = []
|
||||
for axis in fvar.axes:
|
||||
axisTag = axis.axisTag
|
||||
if axisTag in location:
|
||||
continue
|
||||
if axisTag in axisRanges:
|
||||
axis.minValue, axis.maxValue = axisRanges[axisTag]
|
||||
axes.append(axis)
|
||||
fvar.axes = axes
|
||||
|
||||
# only keep NamedInstances whose coordinates == pinned axis location
|
||||
instances = []
|
||||
for instance in fvar.instances:
|
||||
if any(instance.coordinates[axis] != value for axis, value in location.items()):
|
||||
continue
|
||||
for axis in location:
|
||||
del instance.coordinates[axis]
|
||||
for axisTag in location:
|
||||
del instance.coordinates[axisTag]
|
||||
if not isInstanceWithinAxisRanges(instance.coordinates, axisRanges):
|
||||
continue
|
||||
instances.append(instance)
|
||||
fvar.instances = instances
|
||||
|
||||
|
||||
def instantiateSTAT(varfont, location):
|
||||
def instantiateSTAT(varfont, axisLimits):
|
||||
# 'axisLimits' dict must contain user-space (non-normalized) coordinates
|
||||
|
||||
# XXX do something with axisRanges
|
||||
location, axisRanges = splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange)
|
||||
|
||||
pinnedAxes = set(location.keys())
|
||||
|
||||
stat = varfont["STAT"].table
|
||||
@ -758,7 +1103,7 @@ def normalize(value, triple, avarMapping):
|
||||
return floatToFixedToFloat(value, 14)
|
||||
|
||||
|
||||
def normalizeAxisLimits(varfont, axisLimits):
|
||||
def normalizeAxisLimits(varfont, axisLimits, usingAvar=True):
|
||||
fvar = varfont["fvar"]
|
||||
badLimits = set(axisLimits.keys()).difference(a.axisTag for a in fvar.axes)
|
||||
if badLimits:
|
||||
@ -771,15 +1116,15 @@ def normalizeAxisLimits(varfont, axisLimits):
|
||||
}
|
||||
|
||||
avarSegments = {}
|
||||
if "avar" in varfont:
|
||||
if usingAvar and "avar" in varfont:
|
||||
avarSegments = varfont["avar"].segments
|
||||
normalizedLimits = {}
|
||||
for axis_tag, triple in axes.items():
|
||||
avarMapping = avarSegments.get(axis_tag, None)
|
||||
value = axisLimits[axis_tag]
|
||||
if isinstance(value, tuple):
|
||||
normalizedLimits[axis_tag] = tuple(
|
||||
normalize(v, triple, avarMapping) for v in axisLimits[axis_tag]
|
||||
normalizedLimits[axis_tag] = NormalizedAxisRange(
|
||||
*(normalize(v, triple, avarMapping) for v in axisLimits[axis_tag])
|
||||
)
|
||||
else:
|
||||
normalizedLimits[axis_tag] = normalize(value, triple, avarMapping)
|
||||
@ -850,10 +1195,6 @@ def instantiateVariableFont(
|
||||
|
||||
log.info("Normalized limits: %s", normalizedLimits)
|
||||
|
||||
# TODO Remove this check once ranges are supported
|
||||
if any(isinstance(v, tuple) for v in axisLimits.values()):
|
||||
raise NotImplementedError("Axes range limits are not supported yet")
|
||||
|
||||
if "gvar" in varfont:
|
||||
instantiateGvar(varfont, normalizedLimits, optimize=optimize)
|
||||
|
||||
@ -874,7 +1215,7 @@ def instantiateVariableFont(
|
||||
instantiateFeatureVariations(varfont, normalizedLimits)
|
||||
|
||||
if "avar" in varfont:
|
||||
instantiateAvar(varfont, normalizedLimits)
|
||||
instantiateAvar(varfont, axisLimits)
|
||||
|
||||
with pruningUnusedNames(varfont):
|
||||
if "STAT" in varfont:
|
||||
@ -898,6 +1239,23 @@ def instantiateVariableFont(
|
||||
return varfont
|
||||
|
||||
|
||||
def splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange):
|
||||
location, axisRanges = {}, {}
|
||||
for axisTag, value in axisLimits.items():
|
||||
if isinstance(value, rangeType):
|
||||
axisRanges[axisTag] = value
|
||||
elif isinstance(value, (int, float)):
|
||||
location[axisTag] = value
|
||||
elif isinstance(value, tuple):
|
||||
axisRanges[axisTag] = rangeType(*value)
|
||||
else:
|
||||
raise TypeError(
|
||||
f"Expected number or {rangeType.__name__}, "
|
||||
f"got {type(value).__name__}: {value!r}"
|
||||
)
|
||||
return location, axisRanges
|
||||
|
||||
|
||||
def parseLimits(limits):
|
||||
result = {}
|
||||
for limitString in limits:
|
||||
@ -908,12 +1266,12 @@ def parseLimits(limits):
|
||||
if match.group(2): # 'drop'
|
||||
lbound = None
|
||||
else:
|
||||
lbound = float(match.group(3))
|
||||
lbound = strToFixedToFloat(match.group(3), precisionBits=16)
|
||||
ubound = lbound
|
||||
if match.group(4):
|
||||
ubound = float(match.group(4))
|
||||
ubound = strToFixedToFloat(match.group(4), precisionBits=16)
|
||||
if lbound != ubound:
|
||||
result[tag] = (lbound, ubound)
|
||||
result[tag] = AxisRange(lbound, ubound)
|
||||
else:
|
||||
result[tag] = lbound
|
||||
return result
|
||||
|
Loading…
x
Reference in New Issue
Block a user