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
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)

View File

@ -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

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 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,63 +148,53 @@ 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.
else:
# Special-case if peak is at axisMax.
if axisMax == peak:
upper = peak
# Case 3:
# We keep delta as is and only scale the axis upper to achieve
# the desired new tent if feasible.
#
# 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
# | |
# peak
# 1.....................o....................
# / \_|
# ..................../....+_.........outGain
# / | \
# gain..............+......|..+_.............
# /| | | \
# 0---|-----------o | | | o----------1
# axisMin lower| | | upper
# | | newUpper
# axisDef axisMax
#
elif axisDef + (axisMax - axisDef) * 2 >= upper:
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
# Special-case if peak is at axisMax.
if axisMax == peak:
upper = peak
loc = (max(axisDef, lower), peak, upper)
scalar = 1
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))
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\|
# 1.........|......o.|....................
# ..........|...../x\|.............outGain
# | |xxy|\_
# | /xxxy| \_
# | |xxxxy| \_
@ -214,12 +205,11 @@ def _solve(tent, axisLimit, negative=False):
# axisDef axisMax
#
else:
loc1 = (max(axisDef, lower), peak, axisMax)
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!
@ -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

View File

@ -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):

View File

@ -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