Merge pull request #3179 from fonttools/L4-fixes

L4 fixes
This commit is contained in:
Behdad Esfahbod 2023-07-11 10:47:59 -04:00 committed by GitHub
commit fb56e7b7c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 204 additions and 99 deletions

View File

@ -204,8 +204,8 @@ class AxisTriple(Sequence):
default = None default = None
if n == 2: if n == 2:
minimum, maximum = v minimum, maximum = v
elif n == 3: elif n >= 3:
minimum, default, maximum = v return cls(*v)
else: else:
raise ValueError(f"expected sequence of 2 or 3; got {n}: {v!r}") raise ValueError(f"expected sequence of 2 or 3; got {n}: {v!r}")
return cls(minimum, default, maximum) 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]): class _BaseAxisLimits(Mapping[str, AxisTriple]):
def __getitem__(self, key: str) -> AxisTriple: def __getitem__(self, key: str) -> AxisTriple:
return self._data[key] return self._data[key]
@ -334,8 +398,13 @@ class AxisLimits(_BaseAxisLimits):
normalizedLimits = {} normalizedLimits = {}
for axis_tag, triple in axes.items(): for axis_tag, triple in axes.items():
distanceNegative = triple[1] - triple[0]
distancePositive = triple[2] - triple[1]
if self[axis_tag] is None: if self[axis_tag] is None:
normalizedLimits[axis_tag] = NormalizedAxisTriple(0, 0, 0) normalizedLimits[axis_tag] = NormalizedAxisTripleAndDistances(
0, 0, 0, distanceNegative, distancePositive
)
continue continue
minV, defaultV, maxV = self[axis_tag] minV, defaultV, maxV = self[axis_tag]
@ -344,8 +413,10 @@ class AxisLimits(_BaseAxisLimits):
defaultV = triple[1] defaultV = triple[1]
avarMapping = avarSegments.get(axis_tag, None) avarMapping = avarSegments.get(axis_tag, None)
normalizedLimits[axis_tag] = NormalizedAxisTriple( normalizedLimits[axis_tag] = NormalizedAxisTripleAndDistances(
*(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) return NormalizedAxisLimits(normalizedLimits)
@ -358,7 +429,7 @@ class NormalizedAxisLimits(_BaseAxisLimits):
self._data = data = {} self._data = data = {}
for k, v in dict(*args, **kwargs).items(): for k, v in dict(*args, **kwargs).items():
try: try:
triple = NormalizedAxisTriple.expand(v) triple = NormalizedAxisTripleAndDistances.expand(v)
except ValueError as e: except ValueError as e:
raise ValueError(f"Invalid axis limits for {k!r}: {v!r}") from e raise ValueError(f"Invalid axis limits for {k!r}: {v!r}") from e
data[k] = triple data[k] = triple
@ -442,7 +513,7 @@ def changeTupleVariationsAxisLimits(variations, axisLimits):
def changeTupleVariationAxisLimit(var, axisTag, axisLimit): def changeTupleVariationAxisLimit(var, axisTag, axisLimit):
assert isinstance(axisLimit, NormalizedAxisTriple) assert isinstance(axisLimit, NormalizedAxisTripleAndDistances)
# Skip when current axis is missing (i.e. doesn't participate), # Skip when current axis is missing (i.e. doesn't participate),
lower, peak, upper = var.axes.get(axisTag, (-1, 0, 1)) 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." "Instancing accross VarComposite axes with variation is not supported."
) )
limits = axisLimits[tag] limits = axisLimits[tag]
loc = normalizeValue(loc, limits) loc = limits.renormalizeValue(loc, extrapolate=False)
newLocation[tag] = loc newLocation[tag] = loc
component.location = newLocation component.location = newLocation
@ -925,15 +996,21 @@ def instantiateAvar(varfont, axisLimits):
mappedMax = floatToFixedToFloat( mappedMax = floatToFixedToFloat(
piecewiseLinearMap(axisRange.maximum, mapping), 14 piecewiseLinearMap(axisRange.maximum, mapping), 14
) )
mappedAxisLimit = NormalizedAxisTripleAndDistances(
mappedMin,
mappedDef,
mappedMax,
axisRange.distanceNegative,
axisRange.distancePositive,
)
newMapping = {} newMapping = {}
for fromCoord, toCoord in mapping.items(): for fromCoord, toCoord in mapping.items():
if fromCoord < axisRange.minimum or fromCoord > axisRange.maximum: if fromCoord < axisRange.minimum or fromCoord > axisRange.maximum:
continue continue
fromCoord = normalizeValue(fromCoord, axisRange) fromCoord = axisRange.renormalizeValue(fromCoord)
assert mappedMin <= toCoord <= mappedMax assert mappedMin <= toCoord <= mappedMax
toCoord = normalizeValue(toCoord, (mappedMin, mappedDef, mappedMax)) toCoord = mappedAxisLimit.renormalizeValue(toCoord)
fromCoord = floatToFixedToFloat(fromCoord, 14) fromCoord = floatToFixedToFloat(fromCoord, 14)
toCoord = floatToFixedToFloat(toCoord, 14) toCoord = floatToFixedToFloat(toCoord, 14)

View File

@ -1,5 +1,4 @@
from fontTools.ttLib.tables import otTables as ot from fontTools.ttLib.tables import otTables as ot
from fontTools.varLib.models import normalizeValue
from copy import deepcopy from copy import deepcopy
import logging import logging
@ -41,7 +40,9 @@ def _limitFeatureVariationConditionRange(condition, axisLimit):
# condition invalid or out of range # condition invalid or out of range
return 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( def _instantiateFeatureVariationRecord(
@ -50,9 +51,9 @@ def _instantiateFeatureVariationRecord(
applies = True applies = True
shouldKeep = False shouldKeep = False
newConditions = [] 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): for i, condition in enumerate(record.ConditionSet.ConditionTable):
if condition.Format == 1: if condition.Format == 1:
axisIdx = condition.AxisIndex axisIdx = condition.AxisIndex

View File

@ -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 fontTools.misc.fixedTools import MAX_F2DOT14
from functools import lru_cache from functools import lru_cache
@ -12,7 +12,7 @@ def _reverse_negate(v):
def _solve(tent, axisLimit, negative=False): def _solve(tent, axisLimit, negative=False):
axisMin, axisDef, axisMax = axisLimit axisMin, axisDef, axisMax, _distanceNegative, _distancePositive = axisLimit
lower, peak, upper = tent lower, peak, upper = tent
# Mirror the problem such that axisDef <= peak # Mirror the problem such that axisDef <= peak
@ -20,7 +20,9 @@ def _solve(tent, axisLimit, negative=False):
return [ return [
(scalar, _reverse_negate(t) if t is not None else None) (scalar, _reverse_negate(t) if t is not None else None)
for scalar, t in _solve( for scalar, t in _solve(
_reverse_negate(tent), _reverse_negate(axisLimit), not negative _reverse_negate(tent),
axisLimit.reverse_negate(),
not negative,
) )
] ]
# axisDef <= peak # axisDef <= peak
@ -98,9 +100,8 @@ def _solve(tent, axisLimit, negative=False):
# | # |
# crossing # crossing
if gain > outGain: if gain > outGain:
# Crossing point on the axis. # Crossing point on the axis.
crossing = peak + ((1 - gain) * (upper - peak) / (1 - outGain)) crossing = peak + (1 - gain) * (upper - peak)
loc = (axisDef, peak, crossing) loc = (axisDef, peak, crossing)
scalar = 1 scalar = 1
@ -116,7 +117,7 @@ def _solve(tent, axisLimit, negative=False):
# the drawing above. # the drawing above.
if upper >= axisMax: if upper >= axisMax:
loc = (crossing, axisMax, axisMax) loc = (crossing, axisMax, axisMax)
scalar = supportScalar({"tag": axisMax}, {"tag": tent}) scalar = outGain
out.append((scalar - gain, loc)) out.append((scalar - gain, loc))
@ -147,84 +148,73 @@ def _solve(tent, axisLimit, negative=False):
# Eternity justify. # Eternity justify.
loc2 = (upper, axisMax, axisMax) loc2 = (upper, axisMax, axisMax)
scalar2 = supportScalar({"tag": axisMax}, {"tag": tent}) scalar2 = 0
out.append((scalar1 - gain, loc1)) out.append((scalar1 - gain, loc1))
out.append((scalar2 - gain, loc2)) out.append((scalar2 - gain, loc2))
# Case 3: Outermost limit still fits within F2Dot14 bounds; else:
# 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
# Special-case if peak is at axisMax. # Special-case if peak is at axisMax.
if axisMax == peak: if axisMax == peak:
upper = peak upper = peak
loc1 = (max(axisDef, lower), peak, upper) # Case 3:
scalar1 = 1 # 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) loc = (max(axisDef, lower), peak, upper)
scalar2 = 0 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)) out.append((scalar1 - gain, loc1))
if peak < upper: # Don't add a dirac delta!
out.append((scalar2 - gain, loc2)) if peak < axisMax:
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))
# Now, the negative side # Now, the negative side
@ -295,7 +285,7 @@ def rebaseTent(tent, axisLimit):
If tent value is None, that is a special deltaset that should If tent value is None, that is a special deltaset that should
be always-enabled (called "gain").""" be always-enabled (called "gain")."""
axisMin, axisDef, axisMax = axisLimit axisMin, axisDef, axisMax, _distanceNegative, _distancePositive = axisLimit
assert -1 <= axisMin <= axisDef <= axisMax <= +1 assert -1 <= axisMin <= axisDef <= axisMax <= +1
lower, peak, upper = tent lower, peak, upper = tent
@ -305,7 +295,7 @@ def rebaseTent(tent, axisLimit):
sols = _solve(tent, axisLimit) sols = _solve(tent, axisLimit)
n = lambda v: normalizeValue(v, axisLimit, extrapolate=True) n = lambda v: axisLimit.renormalizeValue(v)
sols = [ sols = [
(scalar, (n(v[0]), n(v[1]), n(v[2])) if v is not None else None) (scalar, (n(v[0]), n(v[1]), n(v[2])) if v is not None else None)
for scalar, v in sols for scalar, v in sols

View File

@ -1950,7 +1950,7 @@ class LimitTupleVariationAxisRangesTest:
], ],
) )
def test_positive_var(self, var, axisTag, newMax, expected): 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) self.check_limit_single_var_axis_range(var, axisTag, axisRange, expected)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -2029,7 +2029,7 @@ class LimitTupleVariationAxisRangesTest:
], ],
) )
def test_negative_var(self, var, axisTag, newMin, expected): 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) 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) condition = featureVars.buildConditionTable(0, *oldRange)
result = instancer.featureVars._limitFeatureVariationConditionRange( result = instancer.featureVars._limitFeatureVariationConditionRange(
condition, instancer.NormalizedAxisTriple(*newLimit) condition, instancer.NormalizedAxisTripleAndDistances(*newLimit, 1, 1)
) )
assert result == expected assert result == expected
@ -2094,9 +2094,10 @@ def test_parseLimits_invalid(limits):
@pytest.mark.parametrize( @pytest.mark.parametrize(
"limits, expected", "limits, expected",
[ [
({"wght": (100, 400)}, {"wght": (-1.0, 0, 0)}), # 300, 500 come from the font having 100,400,900 fvar axis limits.
({"wght": (100, 400, 400)}, {"wght": (-1.0, 0, 0)}), ({"wght": (100, 400)}, {"wght": (-1.0, 0, 0, 300, 500)}),
({"wght": (100, 300, 400)}, {"wght": (-1.0, -0.5, 0)}), ({"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): def test_normalizeAxisLimits(varfont, limits, expected):
@ -2113,7 +2114,7 @@ def test_normalizeAxisLimits_no_avar(varfont):
limits = instancer.AxisLimits(wght=(400, 400, 500)) limits = instancer.AxisLimits(wght=(400, 400, 500))
normalized = limits.normalize(varfont) 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): def test_normalizeAxisLimits_missing_from_fvar(varfont):

View File

@ -1,4 +1,5 @@
from fontTools.varLib.instancer import solver from fontTools.varLib.instancer import solver
from fontTools.varLib.instancer import NormalizedAxisTripleAndDistances
import pytest import pytest
@ -91,6 +92,25 @@ class RebaseTentTest(object):
(-1, (-1, -1, 0)), (-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 # Case 3a/1neg
pytest.param( pytest.param(
(0.0, 0.5, 2), (0.0, 0.5, 2),
@ -117,8 +137,7 @@ class RebaseTentTest(object):
(0.25, 0.25, 0.75), (0.25, 0.25, 0.75),
[ [
(0.5, None), (0.5, None),
(0.5, (0, 0.5, 1.5)), (0.5, (0, 0.5, 1.0)),
(-0.5, (0.5, 1.5, 1.5)),
], ],
), ),
# Case 1neg # Case 1neg
@ -242,9 +261,26 @@ class RebaseTentTest(object):
(-1, (-1, -1, -0.0001220703)), (-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): def test_rebaseTent(self, tent, axisRange, expected):
axisRange = NormalizedAxisTripleAndDistances(*axisRange)
sol = solver.rebaseTent(tent, axisRange) sol = solver.rebaseTent(tent, axisRange)
a = pytest.approx a = pytest.approx