From 023ad3a3639547ee1781d81bf38dbd25f75c3386 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Wed, 21 Jun 2023 09:47:08 -0600 Subject: [PATCH 01/11] [instancer/L4] Fix crossing calculation --- Lib/fontTools/varLib/instancer/solver.py | 2 +- Tests/varLib/instancer/solver_test.py | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/instancer/solver.py b/Lib/fontTools/varLib/instancer/solver.py index 468f04277..b604525bb 100644 --- a/Lib/fontTools/varLib/instancer/solver.py +++ b/Lib/fontTools/varLib/instancer/solver.py @@ -100,7 +100,7 @@ def _solve(tent, axisLimit, negative=False): if gain > outGain: # Crossing point on the axis. - crossing = peak + ((1 - gain) * (upper - peak) / (1 - outGain)) + crossing = peak + (1 - gain) * (upper - peak) loc = (axisDef, peak, crossing) scalar = 1 diff --git a/Tests/varLib/instancer/solver_test.py b/Tests/varLib/instancer/solver_test.py index 241a824cb..8b2a8fc47 100644 --- a/Tests/varLib/instancer/solver_test.py +++ b/Tests/varLib/instancer/solver_test.py @@ -91,6 +91,25 @@ class RebaseTentTest(object): (-1, (-1, -1, 0)), ], ), + pytest.param( + (0.0, 0.5, 1), + (0, 0.5, 0.75), + [ + (1, None), + (-0.5, (0, 1, 1)), + (-1, (-1, -1, 0)), + ], + ), + pytest.param( + (0.0, 0.5, 1), + (0, 0.25, 0.8), + [ + (0.5, None), + (0.5, (0, 0.45454545, 0.9090909090)), + (-0.1, (0.9090909090, 1.0, 1.0)), + (-0.5, (-1, -1, 0)), + ], + ), # Case 3a/1neg pytest.param( (0.0, 0.5, 2), From 7385cbbc34080d5ed3e9017eb38945cfb03cfe17 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Wed, 21 Jun 2023 09:53:03 -0600 Subject: [PATCH 02/11] [instancer/L4] Simplify --- Lib/fontTools/varLib/instancer/solver.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Lib/fontTools/varLib/instancer/solver.py b/Lib/fontTools/varLib/instancer/solver.py index b604525bb..c8aca9936 100644 --- a/Lib/fontTools/varLib/instancer/solver.py +++ b/Lib/fontTools/varLib/instancer/solver.py @@ -116,7 +116,7 @@ def _solve(tent, axisLimit, negative=False): # the drawing above. if upper >= axisMax: loc = (crossing, axisMax, axisMax) - scalar = supportScalar({"tag": axisMax}, {"tag": tent}) + scalar = outGain out.append((scalar - gain, loc)) @@ -147,7 +147,7 @@ def _solve(tent, axisLimit, negative=False): # Eternity justify. loc2 = (upper, axisMax, axisMax) - scalar2 = supportScalar({"tag": axisMax}, {"tag": tent}) + scalar2 = outGain out.append((scalar1 - gain, loc1)) out.append((scalar2 - gain, loc2)) @@ -202,8 +202,8 @@ def _solve(tent, axisLimit, negative=False): # cannot be represented as a triangle itself. # # | peak | - # 1.........|......o.|................... - # | /x\| + # 1.........|......o.|.................... + # ..........|...../x\|.............outGain # | |xxy|\_ # | /xxxy| \_ # | |xxxxy| \_ @@ -219,7 +219,7 @@ def _solve(tent, axisLimit, negative=False): scalar1 = 1 loc2 = (peak, axisMax, axisMax) - scalar2 = supportScalar({"tag": axisMax}, {"tag": tent}) + scalar2 = outGain out.append((scalar1 - gain, loc1)) # Don't add a dirac delta! From 94e081611c6b40fa6284049a753479d1038bdb1c Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Wed, 21 Jun 2023 09:57:57 -0600 Subject: [PATCH 03/11] [instancer/L4] Further simplify outGain is always zero in this branch. --- Lib/fontTools/varLib/instancer/solver.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/instancer/solver.py b/Lib/fontTools/varLib/instancer/solver.py index c8aca9936..109efae83 100644 --- a/Lib/fontTools/varLib/instancer/solver.py +++ b/Lib/fontTools/varLib/instancer/solver.py @@ -147,7 +147,7 @@ def _solve(tent, axisLimit, negative=False): # Eternity justify. loc2 = (upper, axisMax, axisMax) - scalar2 = outGain + scalar2 = 0 out.append((scalar1 - gain, loc1)) out.append((scalar2 - gain, loc2)) From 72b6102949489ff72966518a741e72bcc723efc0 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Wed, 21 Jun 2023 12:49:19 -0600 Subject: [PATCH 04/11] [instancer/L4] Fix normalizeValue for L4 solver Imagine a font with current min/default/max of 100,700,1000. And new setting of 100,400,1000. The current normalizeLocation will calculate the new location for 700 to be +.33, whereas it should calculate +.5! This is because 400 translates to -.5, so 700 will be normalized to -1,-.5,+1 and get +.33... We need a special normalizeLocation that is aware of the "distance" between min/default/max, ie. the non-normalized values. Then it will be clear that the distance from 400 to 700 is equal to 700 to 1000, and as such 700 should be normalized to .5, not .33... I'm still trying to figure out the case where avar is present. Store this distance in NormalizeAxisLimit and reach it out in the solver. Fixes https://github.com/fonttools/fonttools/issues/3177 --- Lib/fontTools/varLib/instancer/__init__.py | 29 ++++++++--- Lib/fontTools/varLib/instancer/featureVars.py | 4 +- Lib/fontTools/varLib/instancer/solver.py | 52 ++++++++++++++++--- 3 files changed, 69 insertions(+), 16 deletions(-) diff --git a/Lib/fontTools/varLib/instancer/__init__.py b/Lib/fontTools/varLib/instancer/__init__.py index 42063c616..1e35f9e16 100644 --- a/Lib/fontTools/varLib/instancer/__init__.py +++ b/Lib/fontTools/varLib/instancer/__init__.py @@ -240,6 +240,8 @@ class NormalizedAxisTriple(AxisTriple): minimum: float default: float maximum: float + distanceNegative: float + distancePositive: float def __post_init__(self): if self.default is None: @@ -334,8 +336,13 @@ class AxisLimits(_BaseAxisLimits): normalizedLimits = {} for axis_tag, triple in axes.items(): + distanceNegative = triple[1] - triple[0] + distancePositive = triple[2] - triple[1] + if self[axis_tag] is None: - normalizedLimits[axis_tag] = NormalizedAxisTriple(0, 0, 0) + normalizedLimits[axis_tag] = NormalizedAxisTriple( + 0, 0, 0, distanceNegative, distancePositive + ) continue minV, defaultV, maxV = self[axis_tag] @@ -345,7 +352,9 @@ class AxisLimits(_BaseAxisLimits): avarMapping = avarSegments.get(axis_tag, None) normalizedLimits[axis_tag] = NormalizedAxisTriple( - *(normalize(v, triple, avarMapping) for v in (minV, defaultV, maxV)) + *(normalize(v, triple, avarMapping) for v in (minV, defaultV, maxV)), + distanceNegative, + distancePositive, ) return NormalizedAxisLimits(normalizedLimits) @@ -505,7 +514,7 @@ def _instantiateGvarGlyph( "Instancing accross VarComposite axes with variation is not supported." ) limits = axisLimits[tag] - loc = normalizeValue(loc, limits) + loc = solver.normalizeValue(loc, limits) newLocation[tag] = loc component.location = newLocation @@ -927,13 +936,21 @@ def instantiateAvar(varfont, axisLimits): ) newMapping = {} for fromCoord, toCoord in mapping.items(): - if fromCoord < axisRange.minimum or fromCoord > axisRange.maximum: continue - fromCoord = normalizeValue(fromCoord, axisRange) + fromCoord = solver.normalizeValue(fromCoord, axisRange) assert mappedMin <= toCoord <= mappedMax - toCoord = normalizeValue(toCoord, (mappedMin, mappedDef, mappedMax)) + toCoord = solver.normalizeValue( + toCoord, + ( + mappedMin, + mappedDef, + mappedMax, + axisRange.distanceNegative, + axisRange.distancePositive, + ), + ) fromCoord = floatToFixedToFloat(fromCoord, 14) toCoord = floatToFixedToFloat(toCoord, 14) diff --git a/Lib/fontTools/varLib/instancer/featureVars.py b/Lib/fontTools/varLib/instancer/featureVars.py index d60dca158..dd93eaf25 100644 --- a/Lib/fontTools/varLib/instancer/featureVars.py +++ b/Lib/fontTools/varLib/instancer/featureVars.py @@ -41,7 +41,7 @@ def _limitFeatureVariationConditionRange(condition, axisLimit): # condition invalid or out of range return - return tuple(normalizeValue(v, axisLimit) for v in (minValue, maxValue)) + return tuple(normalizeValue(v, tuple(axisLimit)[:3]) for v in (minValue, maxValue)) def _instantiateFeatureVariationRecord( @@ -52,7 +52,7 @@ def _instantiateFeatureVariationRecord( newConditions = [] from fontTools.varLib.instancer import NormalizedAxisTriple - default_triple = NormalizedAxisTriple(-1, 0, +1) + default_triple = NormalizedAxisTriple(-1, 0, +1, 0, 0) for i, condition in enumerate(record.ConditionSet.ConditionTable): if condition.Format == 1: axisIdx = condition.AxisIndex diff --git a/Lib/fontTools/varLib/instancer/solver.py b/Lib/fontTools/varLib/instancer/solver.py index 109efae83..dbbe4c688 100644 --- a/Lib/fontTools/varLib/instancer/solver.py +++ b/Lib/fontTools/varLib/instancer/solver.py @@ -1,4 +1,4 @@ -from fontTools.varLib.models import supportScalar, normalizeValue +from fontTools.varLib.models import supportScalar from fontTools.misc.fixedTools import MAX_F2DOT14 from functools import lru_cache @@ -11,8 +11,12 @@ def _reverse_negate(v): return (-v[2], -v[1], -v[0]) +def _reverse_negate_axisLimit(v): + return (-v[2], -v[1], -v[0], v[4], v[3]) + + def _solve(tent, axisLimit, negative=False): - axisMin, axisDef, axisMax = axisLimit + axisMin, axisDef, axisMax, distanceNegative, distancePositive = axisLimit lower, peak, upper = tent # Mirror the problem such that axisDef <= peak @@ -20,7 +24,9 @@ def _solve(tent, axisLimit, negative=False): return [ (scalar, _reverse_negate(t) if t is not None else None) for scalar, t in _solve( - _reverse_negate(tent), _reverse_negate(axisLimit), not negative + _reverse_negate(tent), + _reverse_negate_axisLimit(axisLimit), + not negative, ) ] # axisDef <= peak @@ -98,7 +104,6 @@ def _solve(tent, axisLimit, negative=False): # | # crossing if gain > outGain: - # Crossing point on the axis. crossing = peak + (1 - gain) * (upper - peak) @@ -175,7 +180,6 @@ def _solve(tent, axisLimit, negative=False): # axisDef axisMax # elif axisDef + (axisMax - axisDef) * 2 >= upper: - if not negative and axisDef + (axisMax - axisDef) * MAX_F2DOT14 < upper: # we clamp +2.0 to the max F2Dot14 (~1.99994) for convenience upper = axisDef + (axisMax - axisDef) * MAX_F2DOT14 @@ -214,7 +218,6 @@ def _solve(tent, axisLimit, negative=False): # axisDef axisMax # else: - loc1 = (max(axisDef, lower), peak, axisMax) scalar1 = 1 @@ -282,6 +285,39 @@ def _solve(tent, axisLimit, negative=False): return out +@lru_cache(128) +def normalizeValue(v, axisLimit): + lower, default, upper, distanceNegative, distancePositive = axisLimit + assert lower <= default <= upper + + if v == default: + return 0 + + if default < 0: + return -normalizeValue(-v, _reverse_negate_axisLimit(axisLimit)) + + # default >= 0 and v != default + + if v > default: + return (v - default) / (upper - default) + + # v < default + + if lower >= 0: + return (v - default) / (default - lower) + + # lower < 0 and v < default + + totalDistance = distanceNegative * -lower + distancePositive * default + + if v >= 0: + vDistance = (default - v) * distancePositive + else: + vDistance = -v * distanceNegative + distancePositive * default + + return -vDistance / totalDistance + + @lru_cache(128) def rebaseTent(tent, axisLimit): """Given a tuple (lower,peak,upper) "tent" and new axis limits @@ -295,7 +331,7 @@ def rebaseTent(tent, axisLimit): If tent value is None, that is a special deltaset that should be always-enabled (called "gain").""" - axisMin, axisDef, axisMax = axisLimit + axisMin, axisDef, axisMax, distanceNegative, distancePositive = axisLimit assert -1 <= axisMin <= axisDef <= axisMax <= +1 lower, peak, upper = tent @@ -305,7 +341,7 @@ def rebaseTent(tent, axisLimit): sols = _solve(tent, axisLimit) - n = lambda v: normalizeValue(v, axisLimit, extrapolate=True) + n = lambda v: normalizeValue(v, axisLimit) sols = [ (scalar, (n(v[0]), n(v[1]), n(v[2])) if v is not None else None) for scalar, v in sols From 43e5aae018cccd2d77272707e652071fa2e09b42 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Wed, 21 Jun 2023 13:52:49 -0600 Subject: [PATCH 05/11] [instancer/L4] Fix avar mapping as well --- Lib/fontTools/varLib/instancer/__init__.py | 57 +++++++++++++++---- Lib/fontTools/varLib/instancer/featureVars.py | 2 +- Lib/fontTools/varLib/instancer/solver.py | 41 +------------ 3 files changed, 48 insertions(+), 52 deletions(-) diff --git a/Lib/fontTools/varLib/instancer/__init__.py b/Lib/fontTools/varLib/instancer/__init__.py index 1e35f9e16..f26822896 100644 --- a/Lib/fontTools/varLib/instancer/__init__.py +++ b/Lib/fontTools/varLib/instancer/__init__.py @@ -252,6 +252,41 @@ class NormalizedAxisTriple(AxisTriple): f"minimum={self.minimum:g}, default={self.default:g}, maximum={self.maximum:g})" ) + def reverse_negate(self): + v = self + return self.__class__(-v[2], -v[1], -v[0], v[4], v[3]) + + def normalizeValue(self, v): + lower, default, upper, distanceNegative, distancePositive = self + assert lower <= default <= upper + + if v == default: + return 0 + + if default < 0: + return -self.reverse_negate().normalizeValue(-v) + + # default >= 0 and v != default + + if v > default: + return (v - default) / (upper - default) + + # v < default + + if lower >= 0: + return (v - default) / (default - lower) + + # lower < 0 and v < default + + totalDistance = distanceNegative * -lower + distancePositive * default + + if v >= 0: + vDistance = (default - v) * distancePositive + else: + vDistance = -v * distanceNegative + distancePositive * default + + return -vDistance / totalDistance + class _BaseAxisLimits(Mapping[str, AxisTriple]): def __getitem__(self, key: str) -> AxisTriple: @@ -514,7 +549,7 @@ def _instantiateGvarGlyph( "Instancing accross VarComposite axes with variation is not supported." ) limits = axisLimits[tag] - loc = solver.normalizeValue(loc, limits) + loc = limits.normalizeValue(loc) newLocation[tag] = loc component.location = newLocation @@ -934,23 +969,21 @@ def instantiateAvar(varfont, axisLimits): mappedMax = floatToFixedToFloat( piecewiseLinearMap(axisRange.maximum, mapping), 14 ) + mappedAxisLimit = NormalizedAxisTriple( + mappedMin, + mappedDef, + mappedMax, + axisRange.distanceNegative, + axisRange.distancePositive, + ) newMapping = {} for fromCoord, toCoord in mapping.items(): if fromCoord < axisRange.minimum or fromCoord > axisRange.maximum: continue - fromCoord = solver.normalizeValue(fromCoord, axisRange) + fromCoord = axisRange.normalizeValue(fromCoord) assert mappedMin <= toCoord <= mappedMax - toCoord = solver.normalizeValue( - toCoord, - ( - mappedMin, - mappedDef, - mappedMax, - axisRange.distanceNegative, - axisRange.distancePositive, - ), - ) + toCoord = mappedAxisLimit.normalizeValue(toCoord) fromCoord = floatToFixedToFloat(fromCoord, 14) toCoord = floatToFixedToFloat(toCoord, 14) diff --git a/Lib/fontTools/varLib/instancer/featureVars.py b/Lib/fontTools/varLib/instancer/featureVars.py index dd93eaf25..950724756 100644 --- a/Lib/fontTools/varLib/instancer/featureVars.py +++ b/Lib/fontTools/varLib/instancer/featureVars.py @@ -41,7 +41,7 @@ def _limitFeatureVariationConditionRange(condition, axisLimit): # condition invalid or out of range return - return tuple(normalizeValue(v, tuple(axisLimit)[:3]) for v in (minValue, maxValue)) + return tuple(axisLimit.normalizeValue(v) for v in (minValue, maxValue)) def _instantiateFeatureVariationRecord( diff --git a/Lib/fontTools/varLib/instancer/solver.py b/Lib/fontTools/varLib/instancer/solver.py index dbbe4c688..272c66645 100644 --- a/Lib/fontTools/varLib/instancer/solver.py +++ b/Lib/fontTools/varLib/instancer/solver.py @@ -11,10 +11,6 @@ def _reverse_negate(v): return (-v[2], -v[1], -v[0]) -def _reverse_negate_axisLimit(v): - return (-v[2], -v[1], -v[0], v[4], v[3]) - - def _solve(tent, axisLimit, negative=False): axisMin, axisDef, axisMax, distanceNegative, distancePositive = axisLimit lower, peak, upper = tent @@ -25,7 +21,7 @@ def _solve(tent, axisLimit, negative=False): (scalar, _reverse_negate(t) if t is not None else None) for scalar, t in _solve( _reverse_negate(tent), - _reverse_negate_axisLimit(axisLimit), + axisLimit.reverse_negate(), not negative, ) ] @@ -285,39 +281,6 @@ def _solve(tent, axisLimit, negative=False): return out -@lru_cache(128) -def normalizeValue(v, axisLimit): - lower, default, upper, distanceNegative, distancePositive = axisLimit - assert lower <= default <= upper - - if v == default: - return 0 - - if default < 0: - return -normalizeValue(-v, _reverse_negate_axisLimit(axisLimit)) - - # default >= 0 and v != default - - if v > default: - return (v - default) / (upper - default) - - # v < default - - if lower >= 0: - return (v - default) / (default - lower) - - # lower < 0 and v < default - - totalDistance = distanceNegative * -lower + distancePositive * default - - if v >= 0: - vDistance = (default - v) * distancePositive - else: - vDistance = -v * distanceNegative + distancePositive * default - - return -vDistance / totalDistance - - @lru_cache(128) def rebaseTent(tent, axisLimit): """Given a tuple (lower,peak,upper) "tent" and new axis limits @@ -341,7 +304,7 @@ def rebaseTent(tent, axisLimit): sols = _solve(tent, axisLimit) - n = lambda v: normalizeValue(v, axisLimit) + n = lambda v: axisLimit.normalizeValue(v) sols = [ (scalar, (n(v[0]), n(v[1]), n(v[2])) if v is not None else None) for scalar, v in sols From 204532aee3ddd6f79f2b62d034d39f3bce48184a Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Wed, 21 Jun 2023 14:47:52 -0600 Subject: [PATCH 06/11] [instancer/L4] Misc fixes and fix tests --- Lib/fontTools/varLib/instancer/__init__.py | 35 ++++++++++++------- Lib/fontTools/varLib/instancer/featureVars.py | 9 ++--- Tests/varLib/instancer/instancer_test.py | 14 ++++---- Tests/varLib/instancer/solver_test.py | 3 ++ 4 files changed, 37 insertions(+), 24 deletions(-) diff --git a/Lib/fontTools/varLib/instancer/__init__.py b/Lib/fontTools/varLib/instancer/__init__.py index f26822896..8e9e21ee1 100644 --- a/Lib/fontTools/varLib/instancer/__init__.py +++ b/Lib/fontTools/varLib/instancer/__init__.py @@ -204,8 +204,8 @@ class AxisTriple(Sequence): default = None if n == 2: minimum, maximum = v - elif n == 3: - minimum, default, maximum = v + elif n >= 3: + return cls(*v) else: raise ValueError(f"expected sequence of 2 or 3; got {n}: {v!r}") return cls(minimum, default, maximum) @@ -234,14 +234,14 @@ class AxisTriple(Sequence): @dataclasses.dataclass(frozen=True, order=True, repr=False) -class NormalizedAxisTriple(AxisTriple): +class NormalizedAxisTripleAndDistances(AxisTriple): """A triple of (min, default, max) normalized axis values.""" minimum: float default: float maximum: float - distanceNegative: float - distancePositive: float + distanceNegative: Optional[float] = 1 + distancePositive: Optional[float] = 1 def __post_init__(self): if self.default is None: @@ -256,15 +256,18 @@ class NormalizedAxisTriple(AxisTriple): v = self return self.__class__(-v[2], -v[1], -v[0], v[4], v[3]) - def normalizeValue(self, v): + def normalizeValue(self, v, extrapolate=True): lower, default, upper, distanceNegative, distancePositive = self assert lower <= default <= upper + if not extrapolate: + v = max(lower, min(upper, v)) + if v == default: return 0 if default < 0: - return -self.reverse_negate().normalizeValue(-v) + return -self.reverse_negate().normalizeValue(-v, extrapolate=extrapolate) # default >= 0 and v != default @@ -285,6 +288,12 @@ class NormalizedAxisTriple(AxisTriple): else: vDistance = -v * distanceNegative + distancePositive * default + if totalDistance == 0: + # This happens + if default == 0: + return -v / lower + return 0 # Shouldn't happen + return -vDistance / totalDistance @@ -375,7 +384,7 @@ class AxisLimits(_BaseAxisLimits): distancePositive = triple[2] - triple[1] if self[axis_tag] is None: - normalizedLimits[axis_tag] = NormalizedAxisTriple( + normalizedLimits[axis_tag] = NormalizedAxisTripleAndDistances( 0, 0, 0, distanceNegative, distancePositive ) continue @@ -386,7 +395,7 @@ class AxisLimits(_BaseAxisLimits): defaultV = triple[1] avarMapping = avarSegments.get(axis_tag, None) - normalizedLimits[axis_tag] = NormalizedAxisTriple( + normalizedLimits[axis_tag] = NormalizedAxisTripleAndDistances( *(normalize(v, triple, avarMapping) for v in (minV, defaultV, maxV)), distanceNegative, distancePositive, @@ -402,7 +411,7 @@ class NormalizedAxisLimits(_BaseAxisLimits): self._data = data = {} for k, v in dict(*args, **kwargs).items(): try: - triple = NormalizedAxisTriple.expand(v) + triple = NormalizedAxisTripleAndDistances.expand(v) except ValueError as e: raise ValueError(f"Invalid axis limits for {k!r}: {v!r}") from e data[k] = triple @@ -486,7 +495,7 @@ def changeTupleVariationsAxisLimits(variations, axisLimits): def changeTupleVariationAxisLimit(var, axisTag, axisLimit): - assert isinstance(axisLimit, NormalizedAxisTriple) + assert isinstance(axisLimit, NormalizedAxisTripleAndDistances) # Skip when current axis is missing (i.e. doesn't participate), lower, peak, upper = var.axes.get(axisTag, (-1, 0, 1)) @@ -549,7 +558,7 @@ def _instantiateGvarGlyph( "Instancing accross VarComposite axes with variation is not supported." ) limits = axisLimits[tag] - loc = limits.normalizeValue(loc) + loc = limits.normalizeValue(loc, extrapolate=False) newLocation[tag] = loc component.location = newLocation @@ -969,7 +978,7 @@ def instantiateAvar(varfont, axisLimits): mappedMax = floatToFixedToFloat( piecewiseLinearMap(axisRange.maximum, mapping), 14 ) - mappedAxisLimit = NormalizedAxisTriple( + mappedAxisLimit = NormalizedAxisTripleAndDistances( mappedMin, mappedDef, mappedMax, diff --git a/Lib/fontTools/varLib/instancer/featureVars.py b/Lib/fontTools/varLib/instancer/featureVars.py index 950724756..3433577c1 100644 --- a/Lib/fontTools/varLib/instancer/featureVars.py +++ b/Lib/fontTools/varLib/instancer/featureVars.py @@ -1,5 +1,4 @@ from fontTools.ttLib.tables import otTables as ot -from fontTools.varLib.models import normalizeValue from copy import deepcopy import logging @@ -41,7 +40,9 @@ def _limitFeatureVariationConditionRange(condition, axisLimit): # condition invalid or out of range return - return tuple(axisLimit.normalizeValue(v) for v in (minValue, maxValue)) + return tuple( + axisLimit.normalizeValue(v, extrapolate=False) for v in (minValue, maxValue) + ) def _instantiateFeatureVariationRecord( @@ -50,9 +51,9 @@ def _instantiateFeatureVariationRecord( applies = True shouldKeep = False newConditions = [] - from fontTools.varLib.instancer import NormalizedAxisTriple + from fontTools.varLib.instancer import NormalizedAxisTripleAndDistances - default_triple = NormalizedAxisTriple(-1, 0, +1, 0, 0) + default_triple = NormalizedAxisTripleAndDistances(-1, 0, +1, 0, 0) for i, condition in enumerate(record.ConditionSet.ConditionTable): if condition.Format == 1: axisIdx = condition.AxisIndex diff --git a/Tests/varLib/instancer/instancer_test.py b/Tests/varLib/instancer/instancer_test.py index ab64c379d..f722ceb67 100644 --- a/Tests/varLib/instancer/instancer_test.py +++ b/Tests/varLib/instancer/instancer_test.py @@ -1950,7 +1950,7 @@ class LimitTupleVariationAxisRangesTest: ], ) def test_positive_var(self, var, axisTag, newMax, expected): - axisRange = instancer.NormalizedAxisTriple(0, 0, newMax) + axisRange = instancer.NormalizedAxisTripleAndDistances(0, 0, newMax, 1, 1) self.check_limit_single_var_axis_range(var, axisTag, axisRange, expected) @pytest.mark.parametrize( @@ -2029,7 +2029,7 @@ class LimitTupleVariationAxisRangesTest: ], ) def test_negative_var(self, var, axisTag, newMin, expected): - axisRange = instancer.NormalizedAxisTriple(newMin, 0, 0) + axisRange = instancer.NormalizedAxisTripleAndDistances(newMin, 0, 0, 1, 1) self.check_limit_single_var_axis_range(var, axisTag, axisRange, expected) @@ -2052,7 +2052,7 @@ def test_limitFeatureVariationConditionRange(oldRange, newLimit, expected): condition = featureVars.buildConditionTable(0, *oldRange) result = instancer.featureVars._limitFeatureVariationConditionRange( - condition, instancer.NormalizedAxisTriple(*newLimit) + condition, instancer.NormalizedAxisTripleAndDistances(*newLimit, 1, 1) ) assert result == expected @@ -2094,9 +2094,9 @@ def test_parseLimits_invalid(limits): @pytest.mark.parametrize( "limits, expected", [ - ({"wght": (100, 400)}, {"wght": (-1.0, 0, 0)}), - ({"wght": (100, 400, 400)}, {"wght": (-1.0, 0, 0)}), - ({"wght": (100, 300, 400)}, {"wght": (-1.0, -0.5, 0)}), + ({"wght": (100, 400)}, {"wght": (-1.0, 0, 0, 300, 500)}), + ({"wght": (100, 400, 400)}, {"wght": (-1.0, 0, 0, 300, 500)}), + ({"wght": (100, 300, 400)}, {"wght": (-1.0, -0.5, 0, 300, 500)}), ], ) def test_normalizeAxisLimits(varfont, limits, expected): @@ -2113,7 +2113,7 @@ def test_normalizeAxisLimits_no_avar(varfont): limits = instancer.AxisLimits(wght=(400, 400, 500)) normalized = limits.normalize(varfont) - assert normalized["wght"] == pytest.approx((0, 0, 0.2), 1e-4) + assert normalized["wght"] == pytest.approx((0, 0, 0.2, 300, 500), 1e-4) def test_normalizeAxisLimits_missing_from_fvar(varfont): diff --git a/Tests/varLib/instancer/solver_test.py b/Tests/varLib/instancer/solver_test.py index 8b2a8fc47..205405d52 100644 --- a/Tests/varLib/instancer/solver_test.py +++ b/Tests/varLib/instancer/solver_test.py @@ -1,4 +1,5 @@ from fontTools.varLib.instancer import solver +from fontTools.varLib.instancer import NormalizedAxisTripleAndDistances import pytest @@ -264,6 +265,8 @@ class RebaseTentTest(object): ], ) def test_rebaseTent(self, tent, axisRange, expected): + axisRange = NormalizedAxisTripleAndDistances(*axisRange) + sol = solver.rebaseTent(tent, axisRange) a = pytest.approx From 17761cc61658e7ee872650ec36d9555a3f626abc Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Wed, 21 Jun 2023 14:57:08 -0600 Subject: [PATCH 07/11] [instancer/L4] Add tests --- Tests/varLib/instancer/solver_test.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Tests/varLib/instancer/solver_test.py b/Tests/varLib/instancer/solver_test.py index 205405d52..2740a10b0 100644 --- a/Tests/varLib/instancer/solver_test.py +++ b/Tests/varLib/instancer/solver_test.py @@ -262,6 +262,21 @@ class RebaseTentTest(object): (-1, (-1, -1, -0.0001220703)), ], ), + # https://github.com/fonttools/fonttools/issues/3177 + pytest.param( + (0, 1, 1), + (-1, -0.5, +1, 1, 1), + [ + (1.0, (1 / 3, 1.0, 1.0)), + ], + ), + pytest.param( + (0, 1, 1), + (-1, -0.5, +1, 2, 1), + [ + (1.0, (0.5, 1.0, 1.0)), + ], + ), ], ) def test_rebaseTent(self, tent, axisRange, expected): From 10bc7a804aa8e840da89a8a09dc12bbef79288e6 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Wed, 21 Jun 2023 17:45:19 -0600 Subject: [PATCH 08/11] [instancer/L4] Implement an optimization --- Lib/fontTools/varLib/instancer/solver.py | 155 +++++++++++++---------- Tests/varLib/instancer/solver_test.py | 3 +- 2 files changed, 92 insertions(+), 66 deletions(-) diff --git a/Lib/fontTools/varLib/instancer/solver.py b/Lib/fontTools/varLib/instancer/solver.py index 272c66645..1b0405fef 100644 --- a/Lib/fontTools/varLib/instancer/solver.py +++ b/Lib/fontTools/varLib/instancer/solver.py @@ -153,77 +153,104 @@ def _solve(tent, axisLimit, negative=False): out.append((scalar1 - gain, loc1)) out.append((scalar2 - gain, loc2)) - # Case 3: 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 clamp to that range. - # - # A second tent is needed for cases when gain is positive, though we add it - # unconditionally and it will be dropped because scalar ends up 0. - # - # TODO: See if we can just move upper closer to adjust the slope, instead of - # second tent. - # - # | peak | - # 1.........|............o...|.................. - # | /x\ | - # | /xxx\ | - # | /xxxxx\| - # | /xxxxxxx+ - # | /xxxxxxxx|\ - # 0---|-----|------oxxxxxxxxx|xo---------------1 - # axisMin | lower | upper - # | | - # axisDef axisMax - # - elif axisDef + (axisMax - axisDef) * 2 >= upper: - if not negative and axisDef + (axisMax - axisDef) * MAX_F2DOT14 < upper: - # we clamp +2.0 to the max F2Dot14 (~1.99994) for convenience - upper = axisDef + (axisMax - axisDef) * MAX_F2DOT14 - assert peak < upper - + else: # Special-case if peak is at axisMax. if axisMax == peak: upper = peak - loc1 = (max(axisDef, lower), peak, upper) - scalar1 = 1 + # Case pre3: + # we keep deltas as is and only scale the axis upper to achieve + # the desired new tent if feasible. + # + # | peak | + # 1.........|............o...|.................. + # | /x\ | + # | /xxx\ | + # | /xxxxx\| + # | /xxxxxxx+ + # | /xxxxxxxx|\ + # 0---|-----|------oxxxxxxxxx|xo---------------1 + # axisMin | lower | upper + # | | + # axisDef axisMax + # + newUpper = peak + (1 - gain) * (upper - peak) + if axisMax <= newUpper and newUpper <= axisDef + (axisMax - axisDef) * 2: + upper = newUpper + if not negative and axisDef + (axisMax - axisDef) * MAX_F2DOT14 < upper: + # we clamp +2.0 to the max F2Dot14 (~1.99994) for convenience + upper = axisDef + (axisMax - axisDef) * MAX_F2DOT14 + assert peak < upper - loc2 = (peak, upper, upper) - scalar2 = 0 + loc = (max(axisDef, lower), peak, upper) + scalar = 1 + + out.append((scalar - gain, loc)) + + # Case 3: Outermost limit still fits within F2Dot14 bounds; + # We keep axis bound as is. Deltas beyond -1.0 or +1.0 will never be + # applied as implementations must clamp to that range. + # + # A second tent is needed for cases when gain is positive, though we add it + # unconditionally and it will be dropped because scalar ends up 0. + # + # | peak | + # 1.........|............o...|.................. + # | /x\ | + # | /xxx\ | + # | /xxxxx\| + # | /xxxxxxx+ + # | /xxxxxxxx|\ + # 0---|-----|------oxxxxxxxxx|xo---------------1 + # axisMin | lower | upper + # | | + # axisDef axisMax + # + elif axisDef + (axisMax - axisDef) * 2 >= upper: + if not negative and axisDef + (axisMax - axisDef) * MAX_F2DOT14 < upper: + # we clamp +2.0 to the max F2Dot14 (~1.99994) for convenience + upper = axisDef + (axisMax - axisDef) * MAX_F2DOT14 + assert peak < upper + + loc1 = (max(axisDef, lower), peak, upper) + scalar1 = 1 + + loc2 = (peak, upper, upper) + scalar2 = 0 + + # Don't add a dirac delta! + if axisDef < upper: + out.append((scalar1 - gain, loc1)) + if peak < upper: + out.append((scalar2 - gain, loc2)) + + # Case 4: New limit doesn't fit; we need to chop into two tents, + # because the shape of a triangle with part of one side cut off + # cannot be represented as a triangle itself. + # + # | peak | + # 1.........|......o.|.................... + # ..........|...../x\|.............outGain + # | |xxy|\_ + # | /xxxy| \_ + # | |xxxxy| \_ + # | /xxxxy| \_ + # 0---|-----|-oxxxxxx| o----------1 + # axisMin | lower | upper + # | | + # axisDef axisMax + # + else: + loc1 = (max(axisDef, lower), peak, axisMax) + scalar1 = 1 + + loc2 = (peak, axisMax, axisMax) + scalar2 = outGain - # Don't add a dirac delta! - if axisDef < upper: out.append((scalar1 - gain, loc1)) - if peak < upper: - out.append((scalar2 - gain, loc2)) - - # Case 4: New limit doesn't fit; we need to chop into two tents, - # because the shape of a triangle with part of one side cut off - # cannot be represented as a triangle itself. - # - # | peak | - # 1.........|......o.|.................... - # ..........|...../x\|.............outGain - # | |xxy|\_ - # | /xxxy| \_ - # | |xxxxy| \_ - # | /xxxxy| \_ - # 0---|-----|-oxxxxxx| o----------1 - # axisMin | lower | upper - # | | - # axisDef axisMax - # - else: - loc1 = (max(axisDef, lower), peak, axisMax) - scalar1 = 1 - - loc2 = (peak, axisMax, axisMax) - scalar2 = outGain - - out.append((scalar1 - gain, loc1)) - # Don't add a dirac delta! - if peak < axisMax: - out.append((scalar2 - gain, loc2)) + # Don't add a dirac delta! + if peak < axisMax: + out.append((scalar2 - gain, loc2)) # Now, the negative side diff --git a/Tests/varLib/instancer/solver_test.py b/Tests/varLib/instancer/solver_test.py index 2740a10b0..abd2ff563 100644 --- a/Tests/varLib/instancer/solver_test.py +++ b/Tests/varLib/instancer/solver_test.py @@ -137,8 +137,7 @@ class RebaseTentTest(object): (0.25, 0.25, 0.75), [ (0.5, None), - (0.5, (0, 0.5, 1.5)), - (-0.5, (0.5, 1.5, 1.5)), + (0.5, (0, 0.5, 1.0)), ], ), # Case 1neg From ddc484d3845d0e0878e2235101288be4128fba70 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Wed, 21 Jun 2023 18:30:19 -0600 Subject: [PATCH 09/11] [instancer/L4] Add a comment --- Lib/fontTools/varLib/instancer/solver.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Lib/fontTools/varLib/instancer/solver.py b/Lib/fontTools/varLib/instancer/solver.py index 1b0405fef..995078c7e 100644 --- a/Lib/fontTools/varLib/instancer/solver.py +++ b/Lib/fontTools/varLib/instancer/solver.py @@ -175,6 +175,8 @@ def _solve(tent, axisLimit, negative=False): # axisDef axisMax # newUpper = peak + (1 - gain) * (upper - peak) + # I feel like the first condition is always true because + # outGain >= gain. if axisMax <= newUpper and newUpper <= axisDef + (axisMax - axisDef) * 2: upper = newUpper if not negative and axisDef + (axisMax - axisDef) * MAX_F2DOT14 < upper: From 71cca00b05d164fb9ccfb093133f21e8982c7cc9 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Wed, 21 Jun 2023 18:52:26 -0600 Subject: [PATCH 10/11] [instancer/L4] Remove an unreachable code path and better comment --- Lib/fontTools/varLib/instancer/solver.py | 68 ++++++------------------ 1 file changed, 15 insertions(+), 53 deletions(-) diff --git a/Lib/fontTools/varLib/instancer/solver.py b/Lib/fontTools/varLib/instancer/solver.py index 995078c7e..4b63b3cfe 100644 --- a/Lib/fontTools/varLib/instancer/solver.py +++ b/Lib/fontTools/varLib/instancer/solver.py @@ -158,26 +158,25 @@ def _solve(tent, axisLimit, negative=False): if axisMax == peak: upper = peak - # Case pre3: - # we keep deltas as is and only scale the axis upper to achieve + # Case 3: + # We keep delta as is and only scale the axis upper to achieve # the desired new tent if feasible. # - # | peak | - # 1.........|............o...|.................. - # | /x\ | - # | /xxx\ | - # | /xxxxx\| - # | /xxxxxxx+ - # | /xxxxxxxx|\ - # 0---|-----|------oxxxxxxxxx|xo---------------1 - # axisMin | lower | upper - # | | - # axisDef axisMax + # peak + # 1.....................o.................... + # / \_| + # ..................../....+_.........outGain + # / | \ + # gain..............+......|..+_............. + # /| | | \ + # 0---|-----------o | | | o----------1 + # axisMin lower| | | upper + # | | newUpper + # axisDef axisMax # newUpper = peak + (1 - gain) * (upper - peak) - # I feel like the first condition is always true because - # outGain >= gain. - if axisMax <= newUpper and newUpper <= axisDef + (axisMax - axisDef) * 2: + assert axisMax <= newUpper # Because outGain >= gain + if newUpper <= axisDef + (axisMax - axisDef) * 2: upper = newUpper if not negative and axisDef + (axisMax - axisDef) * MAX_F2DOT14 < upper: # we clamp +2.0 to the max F2Dot14 (~1.99994) for convenience @@ -189,43 +188,6 @@ def _solve(tent, axisLimit, negative=False): out.append((scalar - gain, loc)) - # Case 3: Outermost limit still fits within F2Dot14 bounds; - # We keep axis bound as is. Deltas beyond -1.0 or +1.0 will never be - # applied as implementations must clamp to that range. - # - # A second tent is needed for cases when gain is positive, though we add it - # unconditionally and it will be dropped because scalar ends up 0. - # - # | peak | - # 1.........|............o...|.................. - # | /x\ | - # | /xxx\ | - # | /xxxxx\| - # | /xxxxxxx+ - # | /xxxxxxxx|\ - # 0---|-----|------oxxxxxxxxx|xo---------------1 - # axisMin | lower | upper - # | | - # axisDef axisMax - # - elif axisDef + (axisMax - axisDef) * 2 >= upper: - if not negative and axisDef + (axisMax - axisDef) * MAX_F2DOT14 < upper: - # we clamp +2.0 to the max F2Dot14 (~1.99994) for convenience - upper = axisDef + (axisMax - axisDef) * MAX_F2DOT14 - assert peak < upper - - loc1 = (max(axisDef, lower), peak, upper) - scalar1 = 1 - - loc2 = (peak, upper, upper) - scalar2 = 0 - - # Don't add a dirac delta! - if axisDef < upper: - out.append((scalar1 - gain, loc1)) - if peak < upper: - out.append((scalar2 - gain, loc2)) - # Case 4: New limit doesn't fit; we need to chop into two tents, # because the shape of a triangle with part of one side cut off # cannot be represented as a triangle itself. From 0893ba93f8a6c5bd8947a26acd2dee7fe2832556 Mon Sep 17 00:00:00 2001 From: Behdad Esfahbod Date: Mon, 10 Jul 2023 12:42:17 -0600 Subject: [PATCH 11/11] [instancer/L4] Address review comments --- Lib/fontTools/varLib/instancer/__init__.py | 42 +++++++++++++------ Lib/fontTools/varLib/instancer/featureVars.py | 4 +- Lib/fontTools/varLib/instancer/solver.py | 6 +-- Tests/varLib/instancer/instancer_test.py | 3 +- 4 files changed, 37 insertions(+), 18 deletions(-) diff --git a/Lib/fontTools/varLib/instancer/__init__.py b/Lib/fontTools/varLib/instancer/__init__.py index 8e9e21ee1..a8663ec42 100644 --- a/Lib/fontTools/varLib/instancer/__init__.py +++ b/Lib/fontTools/varLib/instancer/__init__.py @@ -234,9 +234,29 @@ class AxisTriple(Sequence): @dataclasses.dataclass(frozen=True, order=True, repr=False) -class NormalizedAxisTripleAndDistances(AxisTriple): +class NormalizedAxisTriple(AxisTriple): """A triple of (min, default, max) normalized axis values.""" + minimum: float + default: float + maximum: float + + def __post_init__(self): + if self.default is None: + object.__setattr__(self, "default", max(self.minimum, min(self.maximum, 0))) + if not (-1.0 <= self.minimum <= self.default <= self.maximum <= 1.0): + raise ValueError( + "Normalized axis values not in -1..+1 range; got " + f"minimum={self.minimum:g}, default={self.default:g}, maximum={self.maximum:g})" + ) + + +@dataclasses.dataclass(frozen=True, order=True, repr=False) +class NormalizedAxisTripleAndDistances(AxisTriple): + """A triple of (min, default, max) normalized axis values, + with distances between min and default, and default and max, + in the *pre-normalized* space.""" + minimum: float default: float maximum: float @@ -256,7 +276,11 @@ class NormalizedAxisTripleAndDistances(AxisTriple): v = self return self.__class__(-v[2], -v[1], -v[0], v[4], v[3]) - def normalizeValue(self, v, extrapolate=True): + def renormalizeValue(self, v, extrapolate=True): + """Renormalizes a normalized value v to the range of this axis, + considering the pre-normalized distances as well as the new + axis limits.""" + lower, default, upper, distanceNegative, distancePositive = self assert lower <= default <= upper @@ -267,7 +291,7 @@ class NormalizedAxisTripleAndDistances(AxisTriple): return 0 if default < 0: - return -self.reverse_negate().normalizeValue(-v, extrapolate=extrapolate) + return -self.reverse_negate().renormalizeValue(-v, extrapolate=extrapolate) # default >= 0 and v != default @@ -288,12 +312,6 @@ class NormalizedAxisTripleAndDistances(AxisTriple): else: vDistance = -v * distanceNegative + distancePositive * default - if totalDistance == 0: - # This happens - if default == 0: - return -v / lower - return 0 # Shouldn't happen - return -vDistance / totalDistance @@ -558,7 +576,7 @@ def _instantiateGvarGlyph( "Instancing accross VarComposite axes with variation is not supported." ) limits = axisLimits[tag] - loc = limits.normalizeValue(loc, extrapolate=False) + loc = limits.renormalizeValue(loc, extrapolate=False) newLocation[tag] = loc component.location = newLocation @@ -989,10 +1007,10 @@ def instantiateAvar(varfont, axisLimits): for fromCoord, toCoord in mapping.items(): if fromCoord < axisRange.minimum or fromCoord > axisRange.maximum: continue - fromCoord = axisRange.normalizeValue(fromCoord) + fromCoord = axisRange.renormalizeValue(fromCoord) assert mappedMin <= toCoord <= mappedMax - toCoord = mappedAxisLimit.normalizeValue(toCoord) + toCoord = mappedAxisLimit.renormalizeValue(toCoord) fromCoord = floatToFixedToFloat(fromCoord, 14) toCoord = floatToFixedToFloat(toCoord, 14) diff --git a/Lib/fontTools/varLib/instancer/featureVars.py b/Lib/fontTools/varLib/instancer/featureVars.py index 3433577c1..350c90a41 100644 --- a/Lib/fontTools/varLib/instancer/featureVars.py +++ b/Lib/fontTools/varLib/instancer/featureVars.py @@ -41,7 +41,7 @@ def _limitFeatureVariationConditionRange(condition, axisLimit): return return tuple( - axisLimit.normalizeValue(v, extrapolate=False) for v in (minValue, maxValue) + axisLimit.renormalizeValue(v, extrapolate=False) for v in (minValue, maxValue) ) @@ -53,7 +53,7 @@ def _instantiateFeatureVariationRecord( newConditions = [] from fontTools.varLib.instancer import NormalizedAxisTripleAndDistances - default_triple = NormalizedAxisTripleAndDistances(-1, 0, +1, 0, 0) + default_triple = NormalizedAxisTripleAndDistances(-1, 0, +1) for i, condition in enumerate(record.ConditionSet.ConditionTable): if condition.Format == 1: axisIdx = condition.AxisIndex diff --git a/Lib/fontTools/varLib/instancer/solver.py b/Lib/fontTools/varLib/instancer/solver.py index 4b63b3cfe..c991fcdcf 100644 --- a/Lib/fontTools/varLib/instancer/solver.py +++ b/Lib/fontTools/varLib/instancer/solver.py @@ -12,7 +12,7 @@ def _reverse_negate(v): def _solve(tent, axisLimit, negative=False): - axisMin, axisDef, axisMax, distanceNegative, distancePositive = axisLimit + axisMin, axisDef, axisMax, _distanceNegative, _distancePositive = axisLimit lower, peak, upper = tent # Mirror the problem such that axisDef <= peak @@ -285,7 +285,7 @@ def rebaseTent(tent, axisLimit): If tent value is None, that is a special deltaset that should be always-enabled (called "gain").""" - axisMin, axisDef, axisMax, distanceNegative, distancePositive = axisLimit + axisMin, axisDef, axisMax, _distanceNegative, _distancePositive = axisLimit assert -1 <= axisMin <= axisDef <= axisMax <= +1 lower, peak, upper = tent @@ -295,7 +295,7 @@ def rebaseTent(tent, axisLimit): sols = _solve(tent, axisLimit) - n = lambda v: axisLimit.normalizeValue(v) + n = lambda v: axisLimit.renormalizeValue(v) sols = [ (scalar, (n(v[0]), n(v[1]), n(v[2])) if v is not None else None) for scalar, v in sols diff --git a/Tests/varLib/instancer/instancer_test.py b/Tests/varLib/instancer/instancer_test.py index f722ceb67..e8bedcf81 100644 --- a/Tests/varLib/instancer/instancer_test.py +++ b/Tests/varLib/instancer/instancer_test.py @@ -1950,7 +1950,7 @@ class LimitTupleVariationAxisRangesTest: ], ) def test_positive_var(self, var, axisTag, newMax, expected): - axisRange = instancer.NormalizedAxisTripleAndDistances(0, 0, newMax, 1, 1) + axisRange = instancer.NormalizedAxisTripleAndDistances(0, 0, newMax) self.check_limit_single_var_axis_range(var, axisTag, axisRange, expected) @pytest.mark.parametrize( @@ -2094,6 +2094,7 @@ def test_parseLimits_invalid(limits): @pytest.mark.parametrize( "limits, expected", [ + # 300, 500 come from the font having 100,400,900 fvar axis limits. ({"wght": (100, 400)}, {"wght": (-1.0, 0, 0, 300, 500)}), ({"wght": (100, 400, 400)}, {"wght": (-1.0, 0, 0, 300, 500)}), ({"wght": (100, 300, 400)}, {"wght": (-1.0, -0.5, 0, 300, 500)}),