diff --git a/Lib/fontTools/varLib/instancer/__init__.py b/Lib/fontTools/varLib/instancer/__init__.py index 42063c616..a8663ec42 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) @@ -251,6 +251,70 @@ class NormalizedAxisTriple(AxisTriple): ) +@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 + distanceNegative: Optional[float] = 1 + distancePositive: Optional[float] = 1 + + 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})" + ) + + def reverse_negate(self): + v = self + return self.__class__(-v[2], -v[1], -v[0], v[4], v[3]) + + 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 + + if not extrapolate: + v = max(lower, min(upper, v)) + + if v == default: + return 0 + + if default < 0: + return -self.reverse_negate().renormalizeValue(-v, extrapolate=extrapolate) + + # 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: return self._data[key] @@ -334,8 +398,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] = NormalizedAxisTripleAndDistances( + 0, 0, 0, distanceNegative, distancePositive + ) continue minV, defaultV, maxV = self[axis_tag] @@ -344,8 +413,10 @@ class AxisLimits(_BaseAxisLimits): defaultV = triple[1] avarMapping = avarSegments.get(axis_tag, None) - normalizedLimits[axis_tag] = NormalizedAxisTriple( - *(normalize(v, triple, avarMapping) for v in (minV, defaultV, maxV)) + normalizedLimits[axis_tag] = NormalizedAxisTripleAndDistances( + *(normalize(v, triple, avarMapping) for v in (minV, defaultV, maxV)), + distanceNegative, + distancePositive, ) return NormalizedAxisLimits(normalizedLimits) @@ -358,7 +429,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 @@ -442,7 +513,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)) @@ -505,7 +576,7 @@ def _instantiateGvarGlyph( "Instancing accross VarComposite axes with variation is not supported." ) limits = axisLimits[tag] - loc = normalizeValue(loc, limits) + loc = limits.renormalizeValue(loc, extrapolate=False) newLocation[tag] = loc component.location = newLocation @@ -925,15 +996,21 @@ def instantiateAvar(varfont, axisLimits): mappedMax = floatToFixedToFloat( piecewiseLinearMap(axisRange.maximum, mapping), 14 ) + mappedAxisLimit = NormalizedAxisTripleAndDistances( + mappedMin, + mappedDef, + mappedMax, + axisRange.distanceNegative, + axisRange.distancePositive, + ) newMapping = {} for fromCoord, toCoord in mapping.items(): - if fromCoord < axisRange.minimum or fromCoord > axisRange.maximum: continue - fromCoord = normalizeValue(fromCoord, axisRange) + fromCoord = axisRange.renormalizeValue(fromCoord) assert mappedMin <= toCoord <= mappedMax - toCoord = normalizeValue(toCoord, (mappedMin, mappedDef, mappedMax)) + 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 d60dca158..350c90a41 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(normalizeValue(v, axisLimit) for v in (minValue, maxValue)) + return tuple( + axisLimit.renormalizeValue(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) + 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 468f04277..c991fcdcf 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 @@ -12,7 +12,7 @@ def _reverse_negate(v): 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 +20,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), + axisLimit.reverse_negate(), + not negative, ) ] # axisDef <= peak @@ -98,9 +100,8 @@ def _solve(tent, axisLimit, negative=False): # | # crossing 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 @@ -116,7 +117,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,84 +148,73 @@ def _solve(tent, axisLimit, negative=False): # Eternity justify. loc2 = (upper, axisMax, axisMax) - scalar2 = supportScalar({"tag": axisMax}, {"tag": tent}) + scalar2 = 0 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 3: + # We keep delta as is and only scale the axis upper to achieve + # the desired new tent if feasible. + # + # peak + # 1.....................o.................... + # / \_| + # ..................../....+_.........outGain + # / | \ + # gain..............+......|..+_............. + # /| | | \ + # 0---|-----------o | | | o----------1 + # axisMin lower| | | upper + # | | newUpper + # axisDef axisMax + # + newUpper = peak + (1 - gain) * (upper - peak) + 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 + 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 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\| - # | |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 = supportScalar({"tag": axisMax}, {"tag": tent}) - - 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 @@ -295,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 = axisLimit + axisMin, axisDef, axisMax, _distanceNegative, _distancePositive = axisLimit assert -1 <= axisMin <= axisDef <= axisMax <= +1 lower, peak, upper = tent @@ -305,7 +295,7 @@ def rebaseTent(tent, axisLimit): sols = _solve(tent, axisLimit) - n = lambda v: normalizeValue(v, axisLimit, extrapolate=True) + 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 ab64c379d..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.NormalizedAxisTriple(0, 0, newMax) + axisRange = instancer.NormalizedAxisTripleAndDistances(0, 0, newMax) 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,10 @@ 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)}), + # 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)}), ], ) def test_normalizeAxisLimits(varfont, limits, expected): @@ -2113,7 +2114,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 241a824cb..abd2ff563 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 @@ -91,6 +92,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), @@ -117,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 @@ -242,9 +261,26 @@ 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): + axisRange = NormalizedAxisTripleAndDistances(*axisRange) + sol = solver.rebaseTent(tent, axisRange) a = pytest.approx