From 550711e1068f75044d05829443a7588cf8f16efc Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 25 Sep 2019 17:27:05 +0100 Subject: [PATCH 01/14] move MAX_F2DOT14 constant to fixedTools --- Lib/fontTools/misc/fixedTools.py | 5 +++++ Lib/fontTools/pens/ttGlyphPen.py | 6 +----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/misc/fixedTools.py b/Lib/fontTools/misc/fixedTools.py index e0ed4436c..036ef1056 100644 --- a/Lib/fontTools/misc/fixedTools.py +++ b/Lib/fontTools/misc/fixedTools.py @@ -21,6 +21,11 @@ __all__ = [ ] +# the max value that can still fit in an F2Dot14: +# 1.99993896484375 +MAX_F2DOT14 = 0x7FFF / (1 << 14) + + def otRound(value): """Round float value to nearest integer towards +Infinity. For fractional values of 0.5 and higher, take the next higher integer; diff --git a/Lib/fontTools/pens/ttGlyphPen.py b/Lib/fontTools/pens/ttGlyphPen.py index 1f0830ef3..3996e3cd8 100644 --- a/Lib/fontTools/pens/ttGlyphPen.py +++ b/Lib/fontTools/pens/ttGlyphPen.py @@ -1,5 +1,6 @@ from fontTools.misc.py23 import * from array import array +from fontTools.misc.fixedTools import MAX_F2DOT14 from fontTools.pens.basePen import LoggingPen from fontTools.pens.transformPen import TransformPen from fontTools.ttLib.tables import ttProgram @@ -11,11 +12,6 @@ from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates __all__ = ["TTGlyphPen"] -# the max value that can still fit in an F2Dot14: -# 1.99993896484375 -MAX_F2DOT14 = 0x7FFF / (1 << 14) - - class TTGlyphPen(LoggingPen): """Pen used for drawing to a TrueType glyph. From 0b746bc38da9855f7f2ab76e4ed31c8d7f22590b Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 18 Sep 2019 17:00:53 +0100 Subject: [PATCH 02/14] 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. --- Lib/fontTools/varLib/instancer.py | 502 +++++++++++++++++++++++++----- 1 file changed, 430 insertions(+), 72 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index ddc5173d9..e5bec9c13 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -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: + if axisRange.maximum == 1.0: + return [var] + elif axisRange.maximum == 0.0: + return [] + + limit = axisRange.minimum if negative else axisRange.maximum + + # 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 + + # 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: - newVariations[axes] = var + 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) - # 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) - - for var in newVariations.values(): - var.roundDeltas() - variations[:] = list(newVariations.values()) - - return defaultVar.coordinates if defaultVar is not None else [] + 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,8 +830,10 @@ def _instantiateFeatureVariations(table, fvarAxes, location): record, i, location, fvarAxes, axisIndexMap ) if shouldKeep: - if _featureVariationRecordIsUnique(record, uniqueRecords): - newRecords.append(record) + shouldKeep = _limitFeatureVariationRecord(record, axisRanges, fvarAxes) + + if shouldKeep and _featureVariationRecordIsUnique(record, uniqueRecords): + newRecords.append(record) if applies and not featureVariationApplied: assert record.FeatureTableSubstitution.Version == 0x00010000 @@ -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 From b8500ac97cd023aae26bc6c4673af8e21c6aa9b7 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Wed, 25 Sep 2019 17:21:50 +0100 Subject: [PATCH 03/14] instancer_test: add tests for restricting axis ranges (L3) --- Tests/varLib/instancer_test.py | 436 +++++++++++++++++++++++++++++++-- 1 file changed, 419 insertions(+), 17 deletions(-) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 468211569..84a1b9795 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1,4 +1,5 @@ from fontTools.misc.py23 import * +from fontTools.misc.fixedTools import floatToFixedToFloat from fontTools import ttLib from fontTools import designspaceLib from fontTools.feaLib.builder import addOpenTypeFeaturesFromString @@ -493,33 +494,40 @@ class TupleVarStoreAdapterTest(object): [TupleVariation({"wdth": (-1.0, -1.0, 0)}, [-12, 8])], ] - def test_dropAxes(self): + def test_rebuildRegions(self): regions = [ {"wght": (-1.0, -1.0, 0)}, {"wght": (0.0, 1.0, 1.0)}, {"wdth": (-1.0, -1.0, 0)}, - {"opsz": (0.0, 1.0, 1.0)}, {"wght": (-1.0, -1.0, 0), "wdth": (-1.0, -1.0, 0)}, - {"wght": (0, 0.5, 1.0), "wdth": (-1.0, -1.0, 0)}, - {"wght": (0.5, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, + {"wght": (0, 1.0, 1.0), "wdth": (-1.0, -1.0, 0)}, ] - axisOrder = ["wght", "wdth", "opsz"] - adapter = instancer._TupleVarStoreAdapter(regions, axisOrder, [], itemCounts=[]) + axisOrder = ["wght", "wdth"] + variations = [] + for region in regions: + variations.append(TupleVariation(region, [100])) + tupleVarData = [variations[:3], variations[3:]] + adapter = instancer._TupleVarStoreAdapter( + regions, axisOrder, tupleVarData, itemCounts=[1, 1] + ) - adapter.dropAxes({"wdth"}) + adapter.rebuildRegions() + + assert adapter.regions == regions + + del tupleVarData[0][2] + tupleVarData[1][0].axes = {"wght": (-1.0, -0.5, 0)} + tupleVarData[1][1].axes = {"wght": (0, 0.5, 1.0)} + + adapter.rebuildRegions() assert adapter.regions == [ {"wght": (-1.0, -1.0, 0)}, {"wght": (0.0, 1.0, 1.0)}, - {"opsz": (0.0, 1.0, 1.0)}, - {"wght": (0.0, 0.5, 1.0)}, - {"wght": (0.5, 1.0, 1.0)}, + {"wght": (-1.0, -0.5, 0)}, + {"wght": (0, 0.5, 1.0)}, ] - adapter.dropAxes({"wght", "opsz"}) - - assert adapter.regions == [] - def test_roundtrip(self, fvarAxes): regions = [ {"wght": (-1.0, -1.0, 0)}, @@ -924,6 +932,208 @@ class InstantiateAvarTest(object): assert "avar" not in varfont + @staticmethod + def quantizeF2Dot14Floats(mapping): + return { + floatToFixedToFloat(k, 14): floatToFixedToFloat(v, 14) + for k, v in mapping.items() + } + + # the following values come from NotoSans-VF.ttf + DFLT_WGHT_MAPPING = { + -1.0: -1.0, + -0.6667: -0.7969, + -0.3333: -0.5, + 0: 0, + 0.2: 0.18, + 0.4: 0.38, + 0.6: 0.61, + 0.8: 0.79, + 1.0: 1.0, + } + + DFLT_WDTH_MAPPING = {-1.0: -1.0, -0.6667: -0.7, -0.3333: -0.36664, 0: 0, 1.0: 1.0} + + @pytest.fixture + def varfont(self): + fvarAxes = ("wght", (100, 400, 900)), ("wdth", (62.5, 100, 100)) + avarSegments = { + "wght": self.quantizeF2Dot14Floats(self.DFLT_WGHT_MAPPING), + "wdth": self.quantizeF2Dot14Floats(self.DFLT_WDTH_MAPPING), + } + varfont = ttLib.TTFont() + varfont["name"] = ttLib.newTable("name") + varLib._add_fvar(varfont, _makeDSAxesDict(fvarAxes), instances=()) + avar = varfont["avar"] = ttLib.newTable("avar") + avar.segments = avarSegments + return varfont + + @pytest.mark.parametrize( + "axisLimits, expectedSegments", + [ + pytest.param( + {"wght": (100, 900)}, + {"wght": DFLT_WGHT_MAPPING, "wdth": DFLT_WDTH_MAPPING}, + id="wght=100:900", + ), + pytest.param( + {"wght": (400, 900)}, + { + "wght": { + -1.0: -1.0, + 0: 0, + 0.2: 0.18, + 0.4: 0.38, + 0.6: 0.61, + 0.8: 0.79, + 1.0: 1.0, + }, + "wdth": DFLT_WDTH_MAPPING, + }, + id="wght=400:900", + ), + pytest.param( + {"wght": (100, 400)}, + { + "wght": { + -1.0: -1.0, + -0.6667: -0.7969, + -0.3333: -0.5, + 0: 0, + 1.0: 1.0, + }, + "wdth": DFLT_WDTH_MAPPING, + }, + id="wght=100:400", + ), + pytest.param( + {"wght": (400, 800)}, + { + "wght": { + -1.0: -1.0, + 0: 0, + 0.25: 0.22784, + 0.50006: 0.48103, + 0.75: 0.77214, + 1.0: 1.0, + }, + "wdth": DFLT_WDTH_MAPPING, + }, + id="wght=400:800", + ), + pytest.param( + {"wght": (400, 700)}, + { + "wght": { + -1.0: -1.0, + 0: 0, + 0.3334: 0.2951, + 0.66675: 0.623, + 1.0: 1.0, + }, + "wdth": DFLT_WDTH_MAPPING, + }, + id="wght=400:700", + ), + pytest.param( + {"wght": (400, 600)}, + { + "wght": {-1.0: -1.0, 0: 0, 0.5: 0.47363, 1.0: 1.0}, + "wdth": DFLT_WDTH_MAPPING, + }, + id="wght=400:600", + ), + pytest.param( + {"wdth": (62.5, 100)}, + { + "wght": DFLT_WGHT_MAPPING, + "wdth": { + -1.0: -1.0, + -0.6667: -0.7, + -0.3333: -0.36664, + 0: 0, + 1.0: 1.0, + }, + }, + id="wdth=62.5:100", + ), + pytest.param( + {"wdth": (70, 100)}, + { + "wght": DFLT_WGHT_MAPPING, + "wdth": { + -1.0: -1.0, + -0.8334: -0.85364, + -0.4166: -0.44714, + 0: 0, + 1.0: 1.0, + }, + }, + id="wdth=70:100", + ), + pytest.param( + {"wdth": (75, 100)}, + { + "wght": DFLT_WGHT_MAPPING, + "wdth": {-1.0: -1.0, -0.49994: -0.52374, 0: 0, 1.0: 1.0}, + }, + id="wdth=75:100", + ), + pytest.param( + {"wdth": (77, 100)}, + { + "wght": DFLT_WGHT_MAPPING, + "wdth": {-1.0: -1.0, -0.54346: -0.56696, 0: 0, 1.0: 1.0}, + }, + id="wdth=77:100", + ), + pytest.param( + {"wdth": (87.5, 100)}, + {"wght": DFLT_WGHT_MAPPING, "wdth": {-1.0: -1.0, 0: 0, 1.0: 1.0}}, + id="wdth=87.5:100", + ), + ], + ) + def test_limit_axes(self, varfont, axisLimits, expectedSegments): + instancer.instantiateAvar(varfont, axisLimits) + + newSegments = varfont["avar"].segments + expectedSegments = { + axisTag: self.quantizeF2Dot14Floats(mapping) + for axisTag, mapping in expectedSegments.items() + } + assert newSegments == expectedSegments + + @pytest.mark.parametrize( + "invalidSegmentMap", + [ + pytest.param({0.5: 0.5}, id="missing-required-maps-1"), + pytest.param({-1.0: -1.0, 1.0: 1.0}, id="missing-required-maps-2"), + pytest.param( + {-1.0: -1.0, 0: 0, 0.5: 0.5, 0.6: 0.4, 1.0: 1.0}, + id="retrograde-value-maps", + ), + ], + ) + def test_drop_invalid_segment_map(self, varfont, invalidSegmentMap, caplog): + varfont["avar"].segments["wght"] = invalidSegmentMap + + with caplog.at_level(logging.WARNING, logger="fontTools.varLib.instancer"): + instancer.instantiateAvar(varfont, {"wght": (100, 400)}) + + assert "Invalid avar" in caplog.text + assert "wght" not in varfont["avar"].segments + + def test_isValidAvarSegmentMap(self): + assert instancer._isValidAvarSegmentMap("FOOO", {}) + assert instancer._isValidAvarSegmentMap("FOOO", {-1.0: -1.0, 0: 0, 1.0: 1.0}) + assert instancer._isValidAvarSegmentMap( + "FOOO", {-1.0: -1.0, 0: 0, 0.5: 0.5, 1.0: 1.0} + ) + assert instancer._isValidAvarSegmentMap( + "FOOO", {-1.0: -1.0, 0: 0, 0.5: 0.5, 0.7: 0.5, 1.0: 1.0} + ) + class InstantiateFvarTest(object): @pytest.mark.parametrize( @@ -1321,12 +1531,204 @@ class InstantiateFeatureVariationsTest(object): assert rec1.ConditionSet.ConditionTable[0].Format == 2 +class LimitTupleVariationAxisRangesTest: + def check_limit_single_var_axis_range(self, var, axisTag, axisRange, expected): + result = instancer.limitTupleVariationAxisRange(var, axisTag, axisRange) + print(result) + + assert len(result) == len(expected) + for v1, v2 in zip(result, expected): + assert v1.coordinates == pytest.approx(v2.coordinates) + assert v1.axes.keys() == v2.axes.keys() + for k in v1.axes: + p, q = v1.axes[k], v2.axes[k] + assert p == pytest.approx(q) + + @pytest.mark.parametrize( + "var, axisTag, newMax, expected", + [ + ( + TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]), + "wdth", + 0.5, + [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100])], + ), + ( + TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]), + "wght", + 0.5, + [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [50, 50])], + ), + ( + TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]), + "wght", + 0.8, + [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [80, 80])], + ), + ( + TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]), + "wght", + 1.0, + [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100])], + ), + (TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100]), "wght", 0.0, []), + (TupleVariation({"wght": (0.5, 1.0, 1.0)}, [100, 100]), "wght", 0.4, []), + ( + TupleVariation({"wght": (0.0, 0.5, 1.0)}, [100, 100]), + "wght", + 0.5, + [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [100, 100])], + ), + ( + TupleVariation({"wght": (0.0, 0.5, 1.0)}, [100, 100]), + "wght", + 0.4, + [TupleVariation({"wght": (0.0, 1.0, 1.0)}, [80, 80])], + ), + ( + TupleVariation({"wght": (0.0, 0.5, 1.0)}, [100, 100]), + "wght", + 0.6, + [TupleVariation({"wght": (0.0, 0.833334, 1.666667)}, [100, 100])], + ), + ( + TupleVariation({"wght": (0.0, 0.2, 1.0)}, [100, 100]), + "wght", + 0.4, + [ + TupleVariation({"wght": (0.0, 0.5, 1.99994)}, [100, 100]), + TupleVariation({"wght": (0.5, 1.0, 1.0)}, [8.33333, 8.33333]), + ], + ), + ( + TupleVariation({"wght": (0.0, 0.2, 1.0)}, [100, 100]), + "wght", + 0.5, + [TupleVariation({"wght": (0.0, 0.4, 1.99994)}, [100, 100])], + ), + ( + TupleVariation({"wght": (0.5, 0.5, 1.0)}, [100, 100]), + "wght", + 0.5, + [TupleVariation({"wght": (1.0, 1.0, 1.0)}, [100, 100])], + ), + ], + ) + def test_positive_var(self, var, axisTag, newMax, expected): + axisRange = instancer.NormalizedAxisRange(0, newMax) + self.check_limit_single_var_axis_range(var, axisTag, axisRange, expected) + + @pytest.mark.parametrize( + "var, axisTag, newMin, expected", + [ + ( + TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]), + "wdth", + -0.5, + [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100])], + ), + ( + TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]), + "wght", + -0.5, + [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [50, 50])], + ), + ( + TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]), + "wght", + -0.8, + [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [80, 80])], + ), + ( + TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]), + "wght", + -1.0, + [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100])], + ), + (TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100]), "wght", 0.0, []), + ( + TupleVariation({"wght": (-1.0, -1.0, -0.5)}, [100, 100]), + "wght", + -0.4, + [], + ), + ( + TupleVariation({"wght": (-1.0, -0.5, 0.0)}, [100, 100]), + "wght", + -0.5, + [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [100, 100])], + ), + ( + TupleVariation({"wght": (-1.0, -0.5, 0.0)}, [100, 100]), + "wght", + -0.4, + [TupleVariation({"wght": (-1.0, -1.0, 0.0)}, [80, 80])], + ), + ( + TupleVariation({"wght": (-1.0, -0.5, 0.0)}, [100, 100]), + "wght", + -0.6, + [TupleVariation({"wght": (-1.666667, -0.833334, 0.0)}, [100, 100])], + ), + ( + TupleVariation({"wght": (-1.0, -0.2, 0.0)}, [100, 100]), + "wght", + -0.4, + [ + TupleVariation({"wght": (-2.0, -0.5, -0.0)}, [100, 100]), + TupleVariation({"wght": (-1.0, -1.0, -0.5)}, [8.33333, 8.33333]), + ], + ), + ( + TupleVariation({"wght": (-1.0, -0.2, 0.0)}, [100, 100]), + "wght", + -0.5, + [TupleVariation({"wght": (-2.0, -0.4, 0.0)}, [100, 100])], + ), + ( + TupleVariation({"wght": (-1.0, -0.5, -0.5)}, [100, 100]), + "wght", + -0.5, + [TupleVariation({"wght": (-1.0, -1.0, -1.0)}, [100, 100])], + ), + ], + ) + def test_negative_var(self, var, axisTag, newMin, expected): + axisRange = instancer.NormalizedAxisRange(newMin, 0) + self.check_limit_single_var_axis_range(var, axisTag, axisRange, expected) + + +@pytest.mark.parametrize( + "oldRange, newRange, expected", + [ + ((1.0, -1.0), (-1.0, 1.0), None), # invalid oldRange min > max + ((0.6, 1.0), (0, 0.5), None), + ((-1.0, -0.6), (-0.5, 0), None), + ((0.4, 1.0), (0, 0.5), (0.8, 1.0)), + ((-1.0, -0.4), (-0.5, 0), (-1.0, -0.8)), + ((0.4, 1.0), (0, 0.4), (1.0, 1.0)), + ((-1.0, -0.4), (-0.4, 0), (-1.0, -1.0)), + ((-0.5, 0.5), (-0.4, 0.4), (-1.0, 1.0)), + ((0, 1.0), (-1.0, 0), (0, 0)), # or None? + ((-1.0, 0), (0, 1.0), (0, 0)), # or None? + ], +) +def test_limitFeatureVariationConditionRange(oldRange, newRange, expected): + condition = featureVars.buildConditionTable(0, *oldRange) + + result = instancer._limitFeatureVariationConditionRange( + condition, instancer.NormalizedAxisRange(*newRange) + ) + + assert result == expected + + @pytest.mark.parametrize( "limits, expected", [ (["wght=400", "wdth=100"], {"wght": 400, "wdth": 100}), (["wght=400:900"], {"wght": (400, 900)}), - (["slnt=11.4"], {"slnt": 11.4}), + (["slnt=11.4"], {"slnt": pytest.approx(11.399994)}), (["ABCD=drop"], {"ABCD": None}), ], ) @@ -1350,9 +1752,9 @@ def test_normalizeAxisLimits_tuple(varfont): def test_normalizeAxisLimits_no_avar(varfont): del varfont["avar"] - normalized = instancer.normalizeAxisLimits(varfont, {"wght": (500, 600)}) + normalized = instancer.normalizeAxisLimits(varfont, {"wght": (400, 500)}) - assert normalized["wght"] == pytest.approx((0.2, 0.4), 1e-4) + assert normalized["wght"] == pytest.approx((0, 0.2), 1e-4) def test_normalizeAxisLimits_missing_from_fvar(varfont): From 3c6ddb0ef8e669e8c1cab81632b5f94841235d66 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 17 Oct 2019 19:02:26 +0100 Subject: [PATCH 04/14] instancer: never drop STAT DesignAxes; only prune out-of-range AxisValues The current method for L1 and L2 partial instacing of STAT table -- i.e. drop all pinned axes are respective axis values -- was incorrect. STAT design axis are a superset of the fvar axes, they describe the relations between members of a font family in which some aspects may be implemented as variation axes within a single VF, others as multiple discrete fonts. When we remove an axis from fvar, we still want to keep the STAT's DesignAxis, as well as the single AxisValue table along that design axis which describes the position of the new instance within the family's stylistic attributes. This means, intantiateAvar will never drop any DesignAxis, but will only drops AxisValue tables when: 1) we're pinning an axis and the desired instance coordinate doesn't exactly equal any of the existing AxisValue records; 2) we're restricting an axis range, and the (nominal) AxisValue falls outside of the desired range. We never add new AxisValue records, as that's a design decision that is outside of the scope of the partial instancer. --- Lib/fontTools/varLib/instancer.py | 92 +++++++++++++++---------------- 1 file changed, 44 insertions(+), 48 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index e5bec9c13..59ad715b4 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -981,61 +981,57 @@ def instantiateFvar(varfont, axisLimits): def instantiateSTAT(varfont, axisLimits): # 'axisLimits' dict must contain user-space (non-normalized) coordinates - # XXX do something with axisRanges + stat = varfont["STAT"].table + if not stat.DesignAxisRecord or not ( + stat.AxisValueArray and stat.AxisValueArray.AxisValue + ): + return # STAT table empty, nothing to do + location, axisRanges = splitAxisLocationAndRanges(axisLimits, rangeType=AxisRange) - pinnedAxes = set(location.keys()) - - stat = varfont["STAT"].table - if not stat.DesignAxisRecord: - return # skip empty STAT table - - designAxes = stat.DesignAxisRecord.Axis - pinnedAxisIndices = { - i for i, axis in enumerate(designAxes) if axis.AxisTag in pinnedAxes - } - - if len(pinnedAxisIndices) == len(designAxes): - log.info("Dropping STAT table") - del varfont["STAT"] - return + def isAxisValueOutsideLimits(axisTag, axisValue): + if axisTag in location and axisValue != location[axisTag]: + return True + elif axisTag in axisRanges: + axisRange = axisRanges[axisTag] + if axisValue < axisRange.minimum or axisValue > axisRange.maximum: + return True + return False log.info("Instantiating STAT table") - # only keep DesignAxis that were not instanced, and build a mapping from old - # to new axis indices - newDesignAxes = [] - axisIndexMap = {} - for i, axis in enumerate(designAxes): - if i not in pinnedAxisIndices: - axisIndexMap[i] = len(newDesignAxes) - newDesignAxes.append(axis) - - if stat.AxisValueArray and stat.AxisValueArray.AxisValue: - # drop all AxisValue tables that reference any of the pinned axes - newAxisValueTables = [] - for axisValueTable in stat.AxisValueArray.AxisValue: - if axisValueTable.Format in (1, 2, 3): - if axisValueTable.AxisIndex in pinnedAxisIndices: - continue - axisValueTable.AxisIndex = axisIndexMap[axisValueTable.AxisIndex] - newAxisValueTables.append(axisValueTable) - elif axisValueTable.Format == 4: - if any( - rec.AxisIndex in pinnedAxisIndices - for rec in axisValueTable.AxisValueRecord - ): - continue - for rec in axisValueTable.AxisValueRecord: - rec.AxisIndex = axisIndexMap[rec.AxisIndex] - newAxisValueTables.append(axisValueTable) + # only keep AxisValues whose axis is not pinned nor restricted, or is pinned at the + # exact (nominal) value, or is restricted but the value is within the new range + designAxes = stat.DesignAxisRecord.Axis + newAxisValueTables = [] + for axisValueTable in stat.AxisValueArray.AxisValue: + axisValueFormat = axisValueTable.Format + if axisValueFormat in (1, 2, 3): + axisTag = designAxes[axisValueTable.AxisIndex].AxisTag + if axisValueFormat == 2: + axisValue = axisValueTable.NominalValue else: - raise NotImplementedError(axisValueTable.Format) - stat.AxisValueArray.AxisValue = newAxisValueTables - stat.AxisValueCount = len(stat.AxisValueArray.AxisValue) + axisValue = axisValueTable.Value + if isAxisValueOutsideLimits(axisTag, axisValue): + continue + elif axisValueFormat == 4: + # drop 'non-analytic' AxisValue if _any_ AxisValueRecord doesn't match + # the pinned location or is outside range + dropAxisValueTable = False + for rec in axisValueTable.AxisValueRecord: + axisTag = designAxes[rec.AxisIndex].AxisTag + axisValue = rec.Value + if isAxisValueOutsideLimits(axisTag, axisValue): + dropAxisValueTable = True + break + if dropAxisValueTable: + continue + else: + log.warn("Unknown AxisValue table format (%s); ignored", axisValueFormat) + newAxisValueTables.append(axisValueTable) - stat.DesignAxisRecord.Axis[:] = newDesignAxes - stat.DesignAxisCount = len(stat.DesignAxisRecord.Axis) + stat.AxisValueArray.AxisValue = newAxisValueTables + stat.AxisValueCount = len(stat.AxisValueArray.AxisValue) def getVariationNameIDs(varfont): From 10d544d6a451616f6dfdb6ec284aadc1f8d86e0d Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 17 Oct 2019 19:03:30 +0100 Subject: [PATCH 05/14] instancer_test: update tests for instantiateSTAT new behavior And add tests for limiting STAT's axis ranges. --- .../varLib/data/PartialInstancerTest2-VF.ttx | 133 +++++++++++++++--- ...tialInstancerTest2-VF-instance-100,100.ttx | 64 ++++++++- ...ialInstancerTest2-VF-instance-100,62.5.ttx | 64 ++++++++- ...tialInstancerTest2-VF-instance-400,100.ttx | 59 +++++++- ...ialInstancerTest2-VF-instance-400,62.5.ttx | 65 ++++++++- ...tialInstancerTest2-VF-instance-900,100.ttx | 64 ++++++++- ...ialInstancerTest2-VF-instance-900,62.5.ttx | 64 ++++++++- Tests/varLib/instancer_test.py | 115 ++++++++++++--- 8 files changed, 578 insertions(+), 50 deletions(-) diff --git a/Tests/varLib/data/PartialInstancerTest2-VF.ttx b/Tests/varLib/data/PartialInstancerTest2-VF.ttx index 0f19bde35..ca9231d87 100644 --- a/Tests/varLib/data/PartialInstancerTest2-VF.ttx +++ b/Tests/varLib/data/PartialInstancerTest2-VF.ttx @@ -1,5 +1,5 @@ - + @@ -14,16 +14,16 @@ - + - - - - - + + + + + @@ -36,10 +36,10 @@ - - - - + + + + @@ -55,10 +55,10 @@ - - - - + + + + @@ -66,8 +66,8 @@ - - + + @@ -107,8 +107,8 @@ - - + + @@ -1037,15 +1037,104 @@ - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx index c64049fed..56b5623f8 100644 --- a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx +++ b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,100.ttx @@ -14,12 +14,12 @@ - + - + @@ -238,6 +238,18 @@ + + Weight + + + Width + + + Thin + + + Regular + Copyright 2015 Google Inc. All Rights Reserved. @@ -283,6 +295,18 @@ http://scripts.sil.org/OFL + + Weight + + + Width + + + Thin + + + Regular + @@ -481,4 +505,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx index 87d0c65c7..d220e1563 100644 --- a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx +++ b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-100,62.5.ttx @@ -14,12 +14,12 @@ - + - + @@ -238,6 +238,18 @@ + + Weight + + + Width + + + Thin + + + ExtraCondensed + Copyright 2015 Google Inc. All Rights Reserved. @@ -283,6 +295,18 @@ http://scripts.sil.org/OFL + + Weight + + + Width + + + Thin + + + ExtraCondensed + @@ -481,4 +505,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx index fc64365eb..b71369e8c 100644 --- a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx +++ b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,100.ttx @@ -14,12 +14,12 @@ - + - + @@ -238,6 +238,15 @@ + + Weight + + + Width + + + Regular + Copyright 2015 Google Inc. All Rights Reserved. @@ -283,6 +292,15 @@ http://scripts.sil.org/OFL + + Weight + + + Width + + + Regular + @@ -481,4 +499,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx index 9b40106fe..9db3e8625 100644 --- a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx +++ b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-400,62.5.ttx @@ -14,12 +14,12 @@ - + - + @@ -238,6 +238,18 @@ + + Weight + + + Width + + + Regular + + + ExtraCondensed + Copyright 2015 Google Inc. All Rights Reserved. @@ -283,6 +295,18 @@ http://scripts.sil.org/OFL + + Weight + + + Width + + + Regular + + + ExtraCondensed + @@ -481,4 +505,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx index 8f8517960..6ae729b19 100644 --- a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx +++ b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,100.ttx @@ -14,12 +14,12 @@ - + - + @@ -238,6 +238,18 @@ + + Weight + + + Width + + + Regular + + + Black + Copyright 2015 Google Inc. All Rights Reserved. @@ -283,6 +295,18 @@ http://scripts.sil.org/OFL + + Weight + + + Width + + + Regular + + + Black + @@ -481,4 +505,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx index bc8c7e9a6..7c923e415 100644 --- a/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx +++ b/Tests/varLib/data/test_results/PartialInstancerTest2-VF-instance-900,62.5.ttx @@ -14,12 +14,12 @@ - + - + @@ -238,6 +238,18 @@ + + Weight + + + Width + + + Black + + + ExtraCondensed + Copyright 2015 Google Inc. All Rights Reserved. @@ -283,6 +295,18 @@ http://scripts.sil.org/OFL + + Weight + + + Width + + + Black + + + ExtraCondensed + @@ -481,4 +505,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index 84a1b9795..c1779240d 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1189,7 +1189,7 @@ class InstantiateSTATTest(object): @pytest.mark.parametrize( "location, expected", [ - ({"wght": 400}, ["Condensed", "Upright"]), + ({"wght": 400}, ["Regular", "Condensed", "Upright"]), ({"wdth": 100}, ["Thin", "Regular", "Black", "Upright"]), ], ) @@ -1199,7 +1199,7 @@ class InstantiateSTATTest(object): stat = varfont["STAT"].table designAxes = {a.AxisTag for a in stat.DesignAxisRecord.Axis} - assert designAxes == {"wght", "wdth", "ital"}.difference(location) + assert designAxes == {"wght", "wdth", "ital"} name = varfont["name"] valueNames = [] @@ -1209,7 +1209,23 @@ class InstantiateSTATTest(object): assert valueNames == expected - def test_skip_empty_table(self, varfont): + def test_skip_table_no_axis_value_array(self, varfont): + varfont["STAT"].table.AxisValueArray = None + + instancer.instantiateSTAT(varfont, {"wght": 100}) + + assert len(varfont["STAT"].table.DesignAxisRecord.Axis) == 3 + assert varfont["STAT"].table.AxisValueArray is None + + def test_skip_table_axis_value_array_empty(self, varfont): + varfont["STAT"].table.AxisValueArray.AxisValue = [] + + instancer.instantiateSTAT(varfont, {"wght": 100}) + + assert len(varfont["STAT"].table.DesignAxisRecord.Axis) == 3 + assert not varfont["STAT"].table.AxisValueArray.AxisValue + + def test_skip_table_no_design_axes(self, varfont): stat = otTables.STAT() stat.Version = 0x00010001 stat.populateDefaults() @@ -1221,21 +1237,88 @@ class InstantiateSTATTest(object): assert not varfont["STAT"].table.DesignAxisRecord - def test_drop_table(self, varfont): - stat = otTables.STAT() - stat.Version = 0x00010001 - stat.populateDefaults() - stat.DesignAxisRecord = otTables.AxisRecordArray() - axis = otTables.AxisRecord() - axis.AxisTag = "wght" - axis.AxisNameID = 0 - axis.AxisOrdering = 0 - stat.DesignAxisRecord.Axis = [axis] - varfont["STAT"].table = stat + @staticmethod + def get_STAT_axis_values(stat): + axes = stat.DesignAxisRecord.Axis + result = [] + for axisValue in stat.AxisValueArray.AxisValue: + if axisValue.Format == 1: + result.append((axes[axisValue.AxisIndex].AxisTag, axisValue.Value)) + elif axisValue.Format == 3: + result.append( + ( + axes[axisValue.AxisIndex].AxisTag, + (axisValue.Value, axisValue.LinkedValue), + ) + ) + elif axisValue.Format == 2: + result.append( + ( + axes[axisValue.AxisIndex].AxisTag, + ( + axisValue.RangeMinValue, + axisValue.NominalValue, + axisValue.RangeMaxValue, + ), + ) + ) + elif axisValue.Format == 4: + result.append( + tuple( + (axes[rec.AxisIndex].AxisTag, rec.Value) + for rec in axisValue.AxisValueRecord + ) + ) + else: + raise AssertionError(axisValue.Format) + return result - instancer.instantiateSTAT(varfont, {"wght": 100}) + def test_limit_axes(self, varfont2): + instancer.instantiateSTAT(varfont2, {"wght": (400, 500), "wdth": (75, 100)}) - assert "STAT" not in varfont + assert len(varfont2["STAT"].table.AxisValueArray.AxisValue) == 5 + assert self.get_STAT_axis_values(varfont2["STAT"].table) == [ + ("wght", (400.0, 700.0)), + ("wght", 500.0), + ("wdth", (93.75, 100.0, 100.0)), + ("wdth", (81.25, 87.5, 93.75)), + ("wdth", (68.75, 75.0, 81.25)), + ] + + def test_limit_axis_value_format_4(self, varfont2): + stat = varfont2["STAT"].table + + axisValue = otTables.AxisValue() + axisValue.Format = 4 + axisValue.AxisValueRecord = [] + for tag, value in (("wght", 575), ("wdth", 90)): + rec = otTables.AxisValueRecord() + rec.AxisIndex = next( + i for i, a in enumerate(stat.DesignAxisRecord.Axis) if a.AxisTag == tag + ) + rec.Value = value + axisValue.AxisValueRecord.append(rec) + stat.AxisValueArray.AxisValue.append(axisValue) + + instancer.instantiateSTAT(varfont2, {"wght": (100, 600)}) + + assert axisValue in varfont2["STAT"].table.AxisValueArray.AxisValue + + instancer.instantiateSTAT(varfont2, {"wdth": (62.5, 87.5)}) + + assert axisValue not in varfont2["STAT"].table.AxisValueArray.AxisValue + + def test_unknown_axis_value_format(self, varfont2, caplog): + stat = varfont2["STAT"].table + axisValue = otTables.AxisValue() + axisValue.Format = 5 + stat.AxisValueArray.AxisValue.append(axisValue) + + with caplog.at_level(logging.WARNING, logger="fontTools.varLib.instancer"): + instancer.instantiateSTAT(varfont2, {"wght": 400}) + + assert "Unknown AxisValue table format (5)" in caplog.text + assert axisValue in varfont2["STAT"].table.AxisValueArray.AxisValue def test_pruningUnusedNames(varfont): From f861c68873dc7300c7aa3ca0bb43d6c2f100828e Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 24 Oct 2019 16:10:05 +0100 Subject: [PATCH 06/14] instancer: keep emptied HVAR table Even if HVAR no longer contains any variations, it's better to keep it because otherwise one would have to check the glyphs' phantom points to confirm that the advance widths (or heights for VVAR) don't vary --- Lib/fontTools/varLib/instancer.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 59ad715b4..30b56ad3e 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -467,8 +467,6 @@ def _instantiateVHVAR(varfont, axisLimits, tableFields): _remapVarIdxMap( vhvar, tableFields.vOrigMapping, varIndexMapping, glyphOrder ) - else: - del varfont[tableTag] def instantiateHVAR(varfont, location): From 9a707a2c1bc82b2ee4611e9bc79ed941d7858a20 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Thu, 24 Oct 2019 17:04:04 +0100 Subject: [PATCH 07/14] instancer_test: test empty HVAR is not dropped --- Tests/varLib/instancer_test.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index c1779240d..d15742297 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -382,6 +382,26 @@ class InstantiateHVARTest(object): assert "HVAR" not in varfont + def test_partial_instance_keep_empty_table(self, varfont): + # Append an additional dummy axis to fvar, for which the current HVAR table + # in our test 'varfont' contains no variation data. + # Instancing the other two wght and wdth axes should leave HVAR table empty, + # to signal there are variations to the glyph's advance widths. + fvar = varfont["fvar"] + axis = _f_v_a_r.Axis() + axis.axisTag = "TEST" + fvar.axes.append(axis) + + instancer.instantiateHVAR(varfont, {"wght": 0, "wdth": 0}) + + assert "HVAR" in varfont + + varStore = varfont["HVAR"].table.VarStore + + assert varStore.VarRegionList.RegionCount == 0 + assert not varStore.VarRegionList.Region + assert varStore.VarRegionList.RegionAxisCount == 1 + class InstantiateItemVariationStoreTest(object): def test_VarRegion_get_support(self): From 8bf82539bbac2c4c87797797e13b11f76c3d5719 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 28 Oct 2019 17:39:15 +0000 Subject: [PATCH 08/14] instancer: update module-level docstring for L3 instancing --- Lib/fontTools/varLib/instancer.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 30b56ad3e..5c9f56fe7 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -4,16 +4,17 @@ The module exports an `instantiateVariableFont` function and CLI that allow to create full instances (i.e. static fonts) from variable fonts, as well as "partial" variable fonts that only contain a subset of the original variation space. -For example, if you wish to pin the width axis to a given location while keeping -the rest of the axes, you can do: +For example, if you wish to pin the width axis to a given location while also +restricting the weight axis to 400..700 range, you can do: -$ fonttools varLib.instancer ./NotoSans-VF.ttf wdth=85 +$ fonttools varLib.instancer ./NotoSans-VF.ttf wdth=85 wght=400:700 See `fonttools varLib.instancer --help` for more info on the CLI options. The module's entry point is the `instantiateVariableFont` function, which takes -a TTFont object and a dict specifying a location along either some or all the axes, -and returns a new TTFont representing respectively a partial or a full instance. +a TTFont object and a dict specifying either axis coodinates or (min, max) ranges, +and returns a new TTFont representing either a partial VF, or full instance if all +the VF axes were given an explicit coordinate. E.g. here's how to pin the wght axis at a given location in a wght+wdth variable font, keeping only the deltas associated with the wdth axis: @@ -50,7 +51,7 @@ Note that, unlike varLib.mutator, when an axis is not mentioned in the input location, the varLib.instancer will keep the axis and the corresponding deltas, whereas mutator implicitly drops the axis at its default coordinate. -The module currently supports only the first two "levels" of partial instancing, +The module currently supports only the first three "levels" of partial instancing, with the rest planned to be implemented in the future, namely: L1) dropping one or more axes while leaving the default tables unmodified; L2) dropping one or more axes while pinning them at non-default locations; From 599d24a9e14c24cd155bdaa5cfa72a741ecc6930 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 15 Nov 2019 17:43:22 +0000 Subject: [PATCH 09/14] instancer: add comments to instantiateAvar --- Lib/fontTools/varLib/instancer.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 5c9f56fe7..5e825206a 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -158,10 +158,10 @@ def instantiateTupleVariationStore( axisLimits, rangeType=NormalizedAxisRange ) + newVariations = variations + if pinnedLocation: newVariations = pinTupleVariationAxes(variations, pinnedLocation) - else: - newVariations = variations if axisRanges: newVariations = limitTupleVariationAxisRanges(newVariations, axisRanges) @@ -871,6 +871,8 @@ def _isValidAvarSegmentMap(axisTag, segmentMap): def instantiateAvar(varfont, axisLimits): + # 'axisLimits' dict must contain user-space (non-normalized) coordinates. + location, axisRanges = splitAxisLocationAndRanges(axisLimits) segments = varfont["avar"].segments @@ -887,6 +889,12 @@ def instantiateAvar(varfont, axisLimits): if axis in segments: del segments[axis] + # First compute the default normalization for axisRanges coordinates: i.e. + # min = -1.0, default = 0, max = +1.0, and in between values interpolated linearly, + # without using the avar table's mappings. + # Then, for each axis' SegmentMap, if we are restricting its, compute the new + # mappings by dividing the key/value pairs by the desired new min/max values, + # dropping any mappings that fall outside the restricted range. normalizedRanges = normalizeAxisLimits(varfont, axisRanges, usingAvar=False) newSegments = {} for axisTag, mapping in segments.items(): From 7d136da8360b627b18d38ef9ce32f2497d784ebc Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 15 Nov 2019 18:01:02 +0000 Subject: [PATCH 10/14] remove stale comment a featureVars' range(0,0) is perfectly valid. --- Lib/fontTools/varLib/instancer.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 5e825206a..39f6238e1 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -733,10 +733,6 @@ def _limitFeatureVariationConditionRange(condition, axisRange): 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) From 6142825d7b7b9b5686c4c0ca3af97c4c82de4508 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 15 Nov 2019 18:35:37 +0000 Subject: [PATCH 11/14] instancer: document case when peak is < 1.0 but outer limit exceeds 2.0 --- Lib/fontTools/varLib/instancer.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 39f6238e1..624ec23e5 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -289,10 +289,14 @@ def limitTupleVariationAxisRange(var, axisTag, axisRange): 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! + # case 4: new limit doesn't fit; we need to chop the deltaset into two 'tents', + # because the shape of a triangle with part of one side cut off cannot be + # represented as a triangle itself. It can be represented as sum of two triangles. + # NOTE: This increases the file size! else: + # duplicate the tent, then adjust lower/peak/upper so that the outermost limit + # of the original tent is +/-2.0, whereas the new tent's starts as the old + # one peaks and maxes out at +/-1.0. newVar = TupleVariation(var.axes, var.coordinates) if negative: var.axes[axisTag] = (-2.0, -1 * newPeak, -1 * newLower) @@ -300,8 +304,11 @@ def limitTupleVariationAxisRange(var, axisTag, axisRange): else: var.axes[axisTag] = (newLower, newPeak, MAX_F2DOT14) newVar.axes[axisTag] = (newPeak, 1.0, 1.0) - # TODO: document optimization + # the new tent's deltas are scaled by the difference between the scalar value + # for the old tent at the desired limit... scalar1 = supportScalar({axisTag: limit}, {axisTag: (lower, peak, upper)}) + # ... and the scalar value for the clamped tent (with outer limit +/-2.0), + # which can be simplified like this: scalar2 = 1 / (2 - newPeak) newVar.scaleDeltas(scalar1 - scalar2) From 12e1a6de37402f79d1292c75cab682ea3f11314a Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 15 Nov 2019 19:02:12 +0000 Subject: [PATCH 12/14] instancer: catch early if input range doesn't include current default --- Lib/fontTools/varLib/instancer.py | 19 +++++++++++++++---- Tests/varLib/instancer_test.py | 5 +++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 624ec23e5..016073f5c 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -1124,13 +1124,24 @@ def normalizeAxisLimits(varfont, axisLimits, usingAvar=True): avarSegments = {} if usingAvar and "avar" in varfont: avarSegments = varfont["avar"].segments + + for axis_tag, (_, default, _) in axes.items(): + value = axisLimits[axis_tag] + if isinstance(value, tuple): + minV, maxV = value + if minV > default or maxV < default: + raise NotImplementedError( + f"Unsupported range {axis_tag}={minV:g}:{maxV:g}; " + f"can't change default position ({axis_tag}={default:g})" + ) + 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] = NormalizedAxisRange( - *(normalize(v, triple, avarMapping) for v in axisLimits[axis_tag]) + *(normalize(v, triple, avarMapping) for v in value) ) else: normalizedLimits[axis_tag] = normalize(value, triple, avarMapping) @@ -1192,15 +1203,15 @@ def instantiateVariableFont( """ sanityCheckVariableTables(varfont) - if not inplace: - varfont = deepcopy(varfont) - axisLimits = populateAxisDefaults(varfont, axisLimits) normalizedLimits = normalizeAxisLimits(varfont, axisLimits) log.info("Normalized limits: %s", normalizedLimits) + if not inplace: + varfont = deepcopy(varfont) + if "gvar" in varfont: instantiateGvar(varfont, normalizedLimits, optimize=optimize) diff --git a/Tests/varLib/instancer_test.py b/Tests/varLib/instancer_test.py index d15742297..3421b1165 100644 --- a/Tests/varLib/instancer_test.py +++ b/Tests/varLib/instancer_test.py @@ -1852,6 +1852,11 @@ def test_normalizeAxisLimits_tuple(varfont): assert normalized == {"wght": (-1.0, 0)} +def test_normalizeAxisLimits_unsupported_range(varfont): + with pytest.raises(NotImplementedError, match="Unsupported range"): + instancer.normalizeAxisLimits(varfont, {"wght": (401, 700)}) + + def test_normalizeAxisLimits_no_avar(varfont): del varfont["avar"] From 0b9404d7a40776803faa99416552e15e31325014 Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Fri, 15 Nov 2019 19:36:57 +0000 Subject: [PATCH 13/14] instancer: rename parameters to more generic 'axisLimits' I use the term 'location' for map of {axis_tag: float} coordinates, 'axisRanges' for a map of {axis_tag: Tuple[float, float]} ranges, and 'axisLimits' to include either single-float coordinates or range tuples. --- Lib/fontTools/varLib/instancer.py | 38 ++++++++++++++++--------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index 016073f5c..dc64619d6 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -315,7 +315,7 @@ def limitTupleVariationAxisRange(var, axisTag, axisRange): return [var, newVar] -def instantiateGvarGlyph(varfont, glyphname, location, optimize=True): +def instantiateGvarGlyph(varfont, glyphname, axisLimits, optimize=True): glyf = varfont["glyf"] coordinates, ctrl = glyf.getCoordinatesAndControls(glyphname, varfont) endPts = ctrl.endPts @@ -327,7 +327,7 @@ def instantiateGvarGlyph(varfont, glyphname, location, optimize=True): if tupleVarStore: defaultDeltas = instantiateTupleVariationStore( - tupleVarStore, location, coordinates, endPts + tupleVarStore, axisLimits, coordinates, endPts ) if defaultDeltas: @@ -355,7 +355,7 @@ def instantiateGvarGlyph(varfont, glyphname, location, optimize=True): var.optimize(coordinates, endPts, isComposite) -def instantiateGvar(varfont, location, optimize=True): +def instantiateGvar(varfont, axisLimits, optimize=True): log.info("Instantiating glyf/gvar tables") gvar = varfont["gvar"] @@ -374,7 +374,7 @@ def instantiateGvar(varfont, location, optimize=True): ), ) for glyphname in glyphnames: - instantiateGvarGlyph(varfont, glyphname, location, optimize=optimize) + instantiateGvarGlyph(varfont, glyphname, axisLimits, optimize=optimize) if not gvar.variations: del varfont["gvar"] @@ -386,12 +386,12 @@ def setCvarDeltas(cvt, deltas): cvt[i] += otRound(delta) -def instantiateCvar(varfont, location): +def instantiateCvar(varfont, axisLimits): log.info("Instantiating cvt/cvar tables") cvar = varfont["cvar"] - defaultDeltas = instantiateTupleVariationStore(cvar.variations, location) + defaultDeltas = instantiateTupleVariationStore(cvar.variations, axisLimits) if defaultDeltas: setCvarDeltas(varfont["cvt "], defaultDeltas) @@ -417,13 +417,13 @@ def setMvarDeltas(varfont, deltas): ) -def instantiateMVAR(varfont, location): +def instantiateMVAR(varfont, axisLimits): log.info("Instantiating MVAR table") mvar = varfont["MVAR"].table fvarAxes = varfont["fvar"].axes varStore = mvar.VarStore - defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, location) + defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, axisLimits) setMvarDeltas(varfont, defaultDeltas) if varStore.VarRegionList.Region: @@ -477,12 +477,12 @@ def _instantiateVHVAR(varfont, axisLimits, tableFields): ) -def instantiateHVAR(varfont, location): - return _instantiateVHVAR(varfont, location, varLib.HVAR_FIELDS) +def instantiateHVAR(varfont, axisLimits): + return _instantiateVHVAR(varfont, axisLimits, varLib.HVAR_FIELDS) -def instantiateVVAR(varfont, location): - return _instantiateVHVAR(varfont, location, varLib.VVAR_FIELDS) +def instantiateVVAR(varfont, axisLimits): + return _instantiateVHVAR(varfont, axisLimits, varLib.VVAR_FIELDS) class _TupleVarStoreAdapter(object): @@ -580,8 +580,9 @@ class _TupleVarStoreAdapter(object): 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 - the remaining regions where only some of the axes were instanced. + Remove regions in which all axes were instanced, or fall outside the new axis + limits. Scale the deltas of the remaining regions where only some of the axes + were instanced. The number of VarData subtables, and the number of items within each, are not modified, in order to keep the existing VariationIndex valid. @@ -590,8 +591,9 @@ def instantiateItemVariationStore(itemVarStore, fvarAxes, axisLimits): Args: varStore: An otTables.VarStore object (Item Variation Store) fvarAxes: list of fvar's Axis objects - location: Dict[str, float] mapping axis tags to normalized axis coordinates. - May not specify coordinates for all the fvar axes. + axisLimits: Dict[str, float] mapping axis tags to normalized axis coordinates + (float) or ranges for restricting an axis' min/max (NormalizedAxisRange). + May not specify coordinates/ranges for all the fvar axes. Returns: defaultDeltas: to be added to the default instance, of type dict of floats @@ -613,7 +615,7 @@ def instantiateItemVariationStore(itemVarStore, fvarAxes, axisLimits): return defaultDeltas -def instantiateOTL(varfont, location): +def instantiateOTL(varfont, axisLimits): # TODO(anthrotype) Support partial instancing of JSTF and BASE tables if ( @@ -633,7 +635,7 @@ def instantiateOTL(varfont, location): varStore = gdef.VarStore fvarAxes = varfont["fvar"].axes - defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, location) + defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, axisLimits) # When VF are built, big lookups may overflow and be broken into multiple # subtables. MutatorMerger (which inherits from AligningMerger) reattaches From dbe20b7217d4a091bcf2042183f6c360a38fef9b Mon Sep 17 00:00:00 2001 From: Cosimo Lupo Date: Mon, 18 Nov 2019 10:51:30 +0000 Subject: [PATCH 14/14] minor: typos and variable names --- Lib/fontTools/varLib/instancer.py | 38 ++++++++++++++++--------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/Lib/fontTools/varLib/instancer.py b/Lib/fontTools/varLib/instancer.py index dc64619d6..f0cb646a0 100644 --- a/Lib/fontTools/varLib/instancer.py +++ b/Lib/fontTools/varLib/instancer.py @@ -279,7 +279,7 @@ def limitTupleVariationAxisRange(var, axisTag, axisRange): # 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. + # or +1.0 will never be applied as implementations must clamp to that range. elif newUpper <= 2.0: if negative: newLower, newPeak, newUpper = _negate(newUpper, newPeak, newLower) @@ -897,9 +897,11 @@ def instantiateAvar(varfont, axisLimits): # First compute the default normalization for axisRanges coordinates: i.e. # min = -1.0, default = 0, max = +1.0, and in between values interpolated linearly, # without using the avar table's mappings. - # Then, for each axis' SegmentMap, if we are restricting its, compute the new + # Then, for each SegmentMap, if we are restricting its axis, compute the new # mappings by dividing the key/value pairs by the desired new min/max values, # dropping any mappings that fall outside the restricted range. + # The keys ('fromCoord') are specified in default normalized coordinate space, + # whereas the values ('toCoord') are "mapped forward" using the SegmentMap. normalizedRanges = normalizeAxisLimits(varfont, axisRanges, usingAvar=False) newSegments = {} for axisTag, mapping in segments.items(): @@ -914,28 +916,28 @@ def instantiateAvar(varfont, axisLimits): piecewiseLinearMap(axisRange.maximum, mapping), 14 ) newMapping = {} - for key, value in mapping.items(): - if key < 0: - if axisRange.minimum == 0 or key < axisRange.minimum: + for fromCoord, toCoord in mapping.items(): + if fromCoord < 0: + if axisRange.minimum == 0 or fromCoord < axisRange.minimum: continue else: - key /= abs(axisRange.minimum) - elif key > 0: - if axisRange.maximum == 0 or key > axisRange.maximum: + fromCoord /= abs(axisRange.minimum) + elif fromCoord > 0: + if axisRange.maximum == 0 or fromCoord > axisRange.maximum: continue else: - key /= axisRange.maximum - if value < 0: + fromCoord /= axisRange.maximum + if toCoord < 0: assert mappedMin != 0 - assert value >= mappedMin - value /= abs(mappedMin) - elif value > 0: + assert toCoord >= mappedMin + toCoord /= abs(mappedMin) + elif toCoord > 0: assert mappedMax != 0 - assert value <= mappedMax - value /= mappedMax - key = floatToFixedToFloat(key, 14) - value = floatToFixedToFloat(value, 14) - newMapping[key] = value + assert toCoord <= mappedMax + toCoord /= mappedMax + fromCoord = floatToFixedToFloat(fromCoord, 14) + toCoord = floatToFixedToFloat(toCoord, 14) + newMapping[fromCoord] = toCoord newMapping.update({-1.0: -1.0, 1.0: 1.0}) newSegments[axisTag] = newMapping else: