From f368dcb4dd1e51892f784184f81940e931c2d8a1 Mon Sep 17 00:00:00 2001 From: Just van Rossum Date: Wed, 12 Oct 2022 16:29:14 +0200 Subject: [PATCH 01/12] Attempt to fix #2843 by computing the axis ranges for interpolation --- Lib/fontTools/varLib/models.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/Lib/fontTools/varLib/models.py b/Lib/fontTools/varLib/models.py index a7e020b00..51e2cfcfe 100644 --- a/Lib/fontTools/varLib/models.py +++ b/Lib/fontTools/varLib/models.py @@ -115,7 +115,7 @@ def normalizeLocation(location, axes): return out -def supportScalar(location, support, ot=True, extrapolate=False): +def supportScalar(location, support, ot=True, extrapolate=False, axisRanges=None): """Returns the scalar multiplier at location, for a master with support. If ot is True, then a peak value of zero for support of an axis means "axis does not participate". That @@ -142,6 +142,8 @@ def supportScalar(location, support, ot=True, extrapolate=False): >>> supportScalar({'wght':4}, {'wght':(0,2,2)}, extrapolate=True) 2.0 """ + if extrapolate and axisRanges is None: + axisRanges = {} scalar = 1.0 for axis, (lower, peak, upper) in support.items(): if ot: @@ -160,18 +162,19 @@ def supportScalar(location, support, ot=True, extrapolate=False): continue if extrapolate: - if v < -1 and lower <= -1: - if peak <= -1 and peak < upper: + axisMin, axisMax = axisRanges.get(axis, (-1, +1)) + if v < axisMin and lower <= axisMin: + if peak <= axisMin and peak < upper: scalar *= (v - upper) / (peak - upper) continue - elif -1 < peak: + elif axisMin < peak: scalar *= (v - lower) / (peak - lower) continue - elif +1 < v and +1 <= upper: - if +1 <= peak and lower < peak: + elif axisMax < v and axisMax <= upper: + if axisMax <= peak and lower < peak: scalar *= (v - lower) / (peak - lower) continue - elif peak < +1: + elif peak < axisMax: scalar *= (v - upper) / (peak - upper) continue @@ -241,6 +244,8 @@ class VariationModel(object): self.origLocations = locations self.axisOrder = axisOrder if axisOrder is not None else [] self.extrapolate = extrapolate + if extrapolate: + self.axisRanges = self.computeAxisRanges(locations) locations = [{k: v for k, v in loc.items() if v != 0.0} for loc in locations] keyFunc = self.getMasterLocationsSortKeyFunc( @@ -265,6 +270,15 @@ class VariationModel(object): self._subModels[key] = subModel return subModel, subList(key, items) + @staticmethod + def computeAxisRanges(locations): + axisRanges = {} + for loc in locations: + for axis, value in loc.items(): + axisMin, axisMax = axisRanges.get(axis, (0, 0)) + axisRanges[axis] = min(value, axisMin), max(value, axisMax) + return axisRanges + @staticmethod def getMasterLocationsSortKeyFunc(locations, axisOrder=[]): if {} not in locations: @@ -439,7 +453,7 @@ class VariationModel(object): return model.getDeltas(items, round=round), model.supports def getScalars(self, loc): - return [supportScalar(loc, support, extrapolate=self.extrapolate) + return [supportScalar(loc, support, extrapolate=self.extrapolate, axisRanges=self.axisRanges) for support in self.supports] @staticmethod From 115874cb05e407bc1b54db68a5474f93a027a954 Mon Sep 17 00:00:00 2001 From: Just van Rossum Date: Wed, 12 Oct 2022 16:41:50 +0200 Subject: [PATCH 02/12] Always ssign axisValues attr --- Lib/fontTools/varLib/models.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/fontTools/varLib/models.py b/Lib/fontTools/varLib/models.py index 51e2cfcfe..710583db5 100644 --- a/Lib/fontTools/varLib/models.py +++ b/Lib/fontTools/varLib/models.py @@ -244,8 +244,7 @@ class VariationModel(object): self.origLocations = locations self.axisOrder = axisOrder if axisOrder is not None else [] self.extrapolate = extrapolate - if extrapolate: - self.axisRanges = self.computeAxisRanges(locations) + self.axisRanges = self.computeAxisRanges(locations) if extrapolate else None locations = [{k: v for k, v in loc.items() if v != 0.0} for loc in locations] keyFunc = self.getMasterLocationsSortKeyFunc( From 053cba36268d05060c586667b6c6d543e86f1ce9 Mon Sep 17 00:00:00 2001 From: Just van Rossum Date: Wed, 12 Oct 2022 16:48:13 +0200 Subject: [PATCH 03/12] use value as default instead of 0 --- Lib/fontTools/varLib/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/models.py b/Lib/fontTools/varLib/models.py index 710583db5..dcdb9b918 100644 --- a/Lib/fontTools/varLib/models.py +++ b/Lib/fontTools/varLib/models.py @@ -274,7 +274,7 @@ class VariationModel(object): axisRanges = {} for loc in locations: for axis, value in loc.items(): - axisMin, axisMax = axisRanges.get(axis, (0, 0)) + axisMin, axisMax = axisRanges.get(axis, (value, value)) axisRanges[axis] = min(value, axisMin), max(value, axisMax) return axisRanges From 4ef44bfbd1a242ec25abc2bac3b12e6b6a9261df Mon Sep 17 00:00:00 2001 From: Just van Rossum Date: Thu, 13 Oct 2022 08:42:37 +0200 Subject: [PATCH 04/12] formatting --- Lib/fontTools/varLib/models.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/varLib/models.py b/Lib/fontTools/varLib/models.py index dcdb9b918..c6cd01a93 100644 --- a/Lib/fontTools/varLib/models.py +++ b/Lib/fontTools/varLib/models.py @@ -452,8 +452,12 @@ class VariationModel(object): return model.getDeltas(items, round=round), model.supports def getScalars(self, loc): - return [supportScalar(loc, support, extrapolate=self.extrapolate, axisRanges=self.axisRanges) - for support in self.supports] + return [ + supportScalar( + loc, support, extrapolate=self.extrapolate, axisRanges=self.axisRanges + ) + for support in self.supports + ] @staticmethod def interpolateFromDeltasAndScalars(deltas, scalars): From 8e527d3b8489086f38f21864953967cb289bf76d Mon Sep 17 00:00:00 2001 From: Just van Rossum Date: Thu, 13 Oct 2022 08:54:28 +0200 Subject: [PATCH 05/12] adjust doctests and doc strings --- Lib/fontTools/varLib/models.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/Lib/fontTools/varLib/models.py b/Lib/fontTools/varLib/models.py index c6cd01a93..04dca92eb 100644 --- a/Lib/fontTools/varLib/models.py +++ b/Lib/fontTools/varLib/models.py @@ -121,6 +121,9 @@ def supportScalar(location, support, ot=True, extrapolate=False, axisRanges=None for support of an axis means "axis does not participate". That is how OpenType Variation Font technology works. + If extrapolate is True, axisRanges must be a dict that maps axis + names to (axisMin, axisMax) tuples. + >>> supportScalar({}, {}) 1.0 >>> supportScalar({'wght':.2}, {}) @@ -137,10 +140,14 @@ def supportScalar(location, support, ot=True, extrapolate=False, axisRanges=None 0.75 >>> supportScalar({'wght':2.5, 'wdth':.5}, {'wght':(0,2,4), 'wdth':(-1,0,+1)}) 0.75 - >>> supportScalar({'wght':4}, {'wght':(0,2,3)}, extrapolate=True) - 2.0 - >>> supportScalar({'wght':4}, {'wght':(0,2,2)}, extrapolate=True) - 2.0 + >>> supportScalar({'wght':3}, {'wght':(0,1,2)}, extrapolate=True, axisRanges={'wght':(0, 2)}) + -1.0 + >>> supportScalar({'wght':-1}, {'wght':(0,1,2)}, extrapolate=True, axisRanges={'wght':(0, 2)}) + -1.0 + >>> supportScalar({'wght':3}, {'wght':(0,2,2)}, extrapolate=True, axisRanges={'wght':(0, 2)}) + 1.5 + >>> supportScalar({'wght':-1}, {'wght':(0,2,2)}, extrapolate=True, axisRanges={'wght':(0, 2)}) + -0.5 """ if extrapolate and axisRanges is None: axisRanges = {} @@ -192,9 +199,8 @@ def supportScalar(location, support, ot=True, extrapolate=False, axisRanges=None class VariationModel(object): """Locations must have the base master at the origin (ie. 0). - If the extrapolate argument is set to True, then location values are - interpretted in the normalized space, ie. in the [-1,+1] range, and - values are extrapolated outside this range. + If the extrapolate argument is set to True, then values are extrapolated + outside the axis range. >>> from pprint import pprint >>> locations = [ \ From 16183b9436fa770faae499b61037a39d86a01fba Mon Sep 17 00:00:00 2001 From: Just van Rossum Date: Thu, 13 Oct 2022 09:03:42 +0200 Subject: [PATCH 06/12] Adjust extrapolate test cases, and added some for extrapolating below the minimum --- Tests/varLib/models_test.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Tests/varLib/models_test.py b/Tests/varLib/models_test.py index e0080129c..715135098 100644 --- a/Tests/varLib/models_test.py +++ b/Tests/varLib/models_test.py @@ -36,10 +36,12 @@ def test_supportScalar(): assert supportScalar({"wght": 0.2}, {}) == 1.0 assert supportScalar({"wght": 0.2}, {"wght": (0, 2, 3)}) == 0.1 assert supportScalar({"wght": 2.5}, {"wght": (0, 2, 4)}) == 0.75 - assert supportScalar({"wght": 4}, {"wght": (0, 2, 2)}) == 0.0 - assert supportScalar({"wght": 4}, {"wght": (0, 2, 2)}, extrapolate=True) == 2.0 - assert supportScalar({"wght": 4}, {"wght": (0, 2, 3)}, extrapolate=True) == 2.0 - assert supportScalar({"wght": 2}, {"wght": (0, 0.75, 1)}, extrapolate=True) == -4.0 + assert supportScalar({"wght": 3}, {"wght": (0, 2, 2)}) == 0.0 + assert supportScalar({"wght": 3}, {"wght": (0, 2, 2)}, extrapolate=True, axisRanges={"wght": (0, 2)}) == 1.5 + assert supportScalar({"wght": -1}, {"wght": (0, 2, 2)}, extrapolate=True, axisRanges={"wght": (0, 2)}) == -0.5 + assert supportScalar({"wght": 3}, {"wght": (0, 1, 2)}, extrapolate=True, axisRanges={"wght": (0, 2)}) == -1.0 + assert supportScalar({"wght": -1}, {"wght": (0, 1, 2)}, extrapolate=True, axisRanges={"wght": (0, 2)}) == -1.0 + assert supportScalar({"wght": 2}, {"wght": (0, 0.75, 1)}, extrapolate=True, axisRanges={"wght": (0, 1)}) == -4.0 @pytest.mark.parametrize( From e02e9dc2958db09fa75c51b59f347f16c023bcfa Mon Sep 17 00:00:00 2001 From: Just van Rossum Date: Thu, 13 Oct 2022 09:07:59 +0200 Subject: [PATCH 07/12] Drop -1,+1 fallback and require the axisRanges dict to be complete --- Lib/fontTools/varLib/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/models.py b/Lib/fontTools/varLib/models.py index 04dca92eb..a1b5f46dc 100644 --- a/Lib/fontTools/varLib/models.py +++ b/Lib/fontTools/varLib/models.py @@ -169,7 +169,7 @@ def supportScalar(location, support, ot=True, extrapolate=False, axisRanges=None continue if extrapolate: - axisMin, axisMax = axisRanges.get(axis, (-1, +1)) + axisMin, axisMax = axisRanges[axis] if v < axisMin and lower <= axisMin: if peak <= axisMin and peak < upper: scalar *= (v - upper) / (peak - upper) From b073fb8f6f941e0e14cd21b73035b91a9ae4d8f2 Mon Sep 17 00:00:00 2001 From: Just van Rossum Date: Thu, 13 Oct 2022 12:27:46 +0200 Subject: [PATCH 08/12] Demand axisRanges is given when extrapolate is True --- Lib/fontTools/varLib/models.py | 2 +- Tests/varLib/models_test.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/models.py b/Lib/fontTools/varLib/models.py index a1b5f46dc..3225b4eff 100644 --- a/Lib/fontTools/varLib/models.py +++ b/Lib/fontTools/varLib/models.py @@ -150,7 +150,7 @@ def supportScalar(location, support, ot=True, extrapolate=False, axisRanges=None -0.5 """ if extrapolate and axisRanges is None: - axisRanges = {} + raise TypeError("axisRanges must be passed when extrapolate is True") scalar = 1.0 for axis, (lower, peak, upper) in support.items(): if ot: diff --git a/Tests/varLib/models_test.py b/Tests/varLib/models_test.py index 715135098..a29041f5d 100644 --- a/Tests/varLib/models_test.py +++ b/Tests/varLib/models_test.py @@ -42,6 +42,8 @@ def test_supportScalar(): assert supportScalar({"wght": 3}, {"wght": (0, 1, 2)}, extrapolate=True, axisRanges={"wght": (0, 2)}) == -1.0 assert supportScalar({"wght": -1}, {"wght": (0, 1, 2)}, extrapolate=True, axisRanges={"wght": (0, 2)}) == -1.0 assert supportScalar({"wght": 2}, {"wght": (0, 0.75, 1)}, extrapolate=True, axisRanges={"wght": (0, 1)}) == -4.0 + with pytest.raises(TypeError): + supportScalar({"wght": 2}, {"wght": (0, 0.75, 1)}, extrapolate=True, axisRanges=None) @pytest.mark.parametrize( From a91e4d3595de208fc0dfc333481b24766459c109 Mon Sep 17 00:00:00 2001 From: Just van Rossum Date: Thu, 13 Oct 2022 12:48:59 +0200 Subject: [PATCH 09/12] An omitted axis in a location implies a value of 0 -- we need to records that value --- Lib/fontTools/varLib/models.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Lib/fontTools/varLib/models.py b/Lib/fontTools/varLib/models.py index 3225b4eff..cef5ec907 100644 --- a/Lib/fontTools/varLib/models.py +++ b/Lib/fontTools/varLib/models.py @@ -278,9 +278,11 @@ class VariationModel(object): @staticmethod def computeAxisRanges(locations): axisRanges = {} + allAxes = {axis for loc in locations for axis in loc.keys()} for loc in locations: - for axis, value in loc.items(): - axisMin, axisMax = axisRanges.get(axis, (value, value)) + for axis in allAxes: + value = loc.get(axis, 0) + axisMin, axisMax = axisRanges.get(axis, (0, 0)) axisRanges[axis] = min(value, axisMin), max(value, axisMax) return axisRanges From 2afac999ef37971f23d583b7169b835d0ff7262d Mon Sep 17 00:00:00 2001 From: Just van Rossum Date: Thu, 13 Oct 2022 12:50:21 +0200 Subject: [PATCH 10/12] Add test for VariationModel with extrapolate=True, and test a two-dimensional designspace for expected interpolation values --- Tests/varLib/models_test.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Tests/varLib/models_test.py b/Tests/varLib/models_test.py index a29041f5d..7b64a8cc3 100644 --- a/Tests/varLib/models_test.py +++ b/Tests/varLib/models_test.py @@ -46,6 +46,34 @@ def test_supportScalar(): supportScalar({"wght": 2}, {"wght": (0, 0.75, 1)}, extrapolate=True, axisRanges=None) +def test_model_extrapolate(): + locations = [{}, {"a": 1}, {"b": 1}, {"a": 1, "b": 1}] + model = VariationModel(locations, extrapolate=True) + masterValues = [ + 100, 200, + 300, 400] + testLocsAndValues = [ + ({"a": -1, "b": -1}, -200), + ({"a": -1, "b": 0}, 0), + ({"a": -1, "b": 1}, 200), + ({"a": -1, "b": 2}, 400), + ({"a": 0, "b": -1}, -100), + ({"a": 0, "b": 0}, 100), + ({"a": 0, "b": 1}, 300), + ({"a": 0, "b": 2}, 500), + ({"a": 1, "b": -1}, 0), + ({"a": 1, "b": 0}, 200), + ({"a": 1, "b": 1}, 400), + ({"a": 1, "b": 2}, 600), + ({"a": 2, "b": -1}, 100), + ({"a": 2, "b": 0}, 300), + ({"a": 2, "b": 1}, 500), + ({"a": 2, "b": 2}, 700), + ] + for loc, expectedValue in testLocsAndValues: + assert expectedValue == model.interpolateFromMasters(loc, masterValues) + + @pytest.mark.parametrize( "numLocations, numSamples", [ From 668b8d094bbca816e417fce27df7a235cad051e7 Mon Sep 17 00:00:00 2001 From: Just van Rossum Date: Thu, 13 Oct 2022 12:54:19 +0200 Subject: [PATCH 11/12] Fallback should be value, not 0 --- Lib/fontTools/varLib/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/fontTools/varLib/models.py b/Lib/fontTools/varLib/models.py index cef5ec907..1ea89aab0 100644 --- a/Lib/fontTools/varLib/models.py +++ b/Lib/fontTools/varLib/models.py @@ -282,7 +282,7 @@ class VariationModel(object): for loc in locations: for axis in allAxes: value = loc.get(axis, 0) - axisMin, axisMax = axisRanges.get(axis, (0, 0)) + axisMin, axisMax = axisRanges.get(axis, (value, value)) axisRanges[axis] = min(value, axisMin), max(value, axisMax) return axisRanges From 8b8fbf390ef52c5861d98abb74663f021a29d183 Mon Sep 17 00:00:00 2001 From: Just van Rossum Date: Thu, 13 Oct 2022 12:56:04 +0200 Subject: [PATCH 12/12] formatting --- Tests/varLib/models_test.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Tests/varLib/models_test.py b/Tests/varLib/models_test.py index 7b64a8cc3..58a047bb1 100644 --- a/Tests/varLib/models_test.py +++ b/Tests/varLib/models_test.py @@ -49,9 +49,7 @@ def test_supportScalar(): def test_model_extrapolate(): locations = [{}, {"a": 1}, {"b": 1}, {"a": 1, "b": 1}] model = VariationModel(locations, extrapolate=True) - masterValues = [ - 100, 200, - 300, 400] + masterValues = [100, 200, 300, 400] testLocsAndValues = [ ({"a": -1, "b": -1}, -200), ({"a": -1, "b": 0}, 0),