+
Skip to content

Conversation

Hoolean
Copy link
Contributor

@Hoolean Hoolean commented Sep 19, 2025

(aims to facilitate googlefonts/ufo2ft#945)

Previously, calculating an individual interpolated value did not account for avar mapping. This commit fixes this, as well as allowing for interpolation and other common operations to be performed on fonts prior to compilation by decoupling from the exact ttLib representations of axes.

Some refactoring has been undergone to assist with this, including adding some more types and docs, and splitting the builder into a separate class to limit the amount of state in the VariableScalar class itself.

The fonttools and ufo2ft test suites pass with these changes, although there is no coverage for the designspace-based interpolation yet. This should be introduced before merging.

TODOs

  • Improve test coverage

(aims to facilitate googlefonts/ufo2ft#945)

Previously, calculating an individual interpolated value did not account
for avar mapping. This commit fixes this, as well as allowing for
interpolation and other common operations to be performed on fonts prior
to compilation by decoupling from the exact ttLib representations of
axes.

Some refactoring has been undergone to assist with this, including
adding some more types and docs, and splitting the builder into a
separate class to limit the amount of state in the VariableScalar class
itself.

The fonttools and ufo2ft test suites pass with these changes, although
there is no coverage for the designspace-based interpolation yet. This
should be introduced before merging.
@Hoolean Hoolean force-pushed the varsca-interpolation branch from 7a0c6db to 3ce01a0 Compare September 19, 2025 14:25
@Hoolean Hoolean force-pushed the varsca-interpolation branch from 82e1fe8 to 3b9dcdf Compare September 19, 2025 15:16
@Hoolean Hoolean marked this pull request as ready for review September 22, 2025 07:43
@Hoolean
Copy link
Contributor Author

Hoolean commented Sep 22, 2025

I've added coverage for pre-compiled interpolation and interpolating a single value now 🚀

@anthrotype Is there anything we want to do to confirm that the full stack is still building GPOS correctly aside from running the ufo2ft test suite? Otherwise, ready for review.

self.values[Location(location)] = value
def normalised_values(self, scalar: VariableScalar) -> dict[LocationTuple, int]:
"""Get the values of a variable scalar with fully-specified, normalized
and validated user-locations."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these are no longe "user" locations, are they? The normalise_location method would turn them into fully normalized and avar-mapped locations

Suggested change
and validated user-locations."""
and validated locations."""

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

avar-mapping happens as a step after normalise_location so that we we don't have to calculate it before the lookup in model_cache; maybe the complexity of the half-way approach isn't worth it though

Copy link
Contributor Author

@Hoolean Hoolean Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this convinces me that model_cache should use one of the following as a key instead:

  • Pre-normalized but unmapped user locations
    • Cheap to compute cache key, but more misses
    • This is what was used before
  • Fully-normalized and mapped user locations
    • More expensive to compute cache key, but fewer misses

I suspect the cache was added to optimise for the case where there is a large machine-generated feature code file (e.g. variable kerning), and so I lean towards the former

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, so here the "normalized user" coordinates are the user coordinates normalized using the fvar's user-space axis triplets only, before doing the avar normalization. Usually when I see "normalized" I think about the end result, i.e. the internal normalized coordinates after the avar mapping. The user coordinates pre-normalized are just an intermediate step to avar-normalized coordinates, so it's strange to seem them used here like this..

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think before the model_cache was keyed on pre-normalized user locations, the same that VariableScalar values are, not "pre-normalized design locations" as you said above. Isn't VariableScalar specified in user-space coordinates? If not, why would avar be needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, that was my typo above, I meant 'user' everywhere I said 'design' 🔀

Comment on lines +216 to +217
values = list(scalar.values.values())
return self.model(scalar).getDeltasAndSupports(values)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it might be a contrived example but scalar.values could in theory contain duplicate locations e.g. if repeated twice with implicit/explicit defaults:

feature kern {
    pos two <0 (wght=200:10 wght=200,wdth=100:20) 0 0>;
} kern;
scalar = VariableScalar()
scalar.add_value({"wght": 200.0}, 10)  # omits wdth (defaults to 100)
scalar.add_value({"wght": 200.0, "width": 100.0}, 20)  # explicitly sets wdth to default

That in turn would make VariationModel.getDeltas() trigger an assertion at assert len(masterValues) == len(self.deltaWeights).

Did you use the raw scalar.values intentionally? Or maybe you meant to do self.normalised_values(scalar) which would deduplicate keys (effectively the last wins):

Suggested change
values = list(scalar.values.values())
return self.model(scalar).getDeltasAndSupports(values)
values = list(self.normalised_values(scalar).values())
return self.model(scalar).getDeltasAndSupports(values)

In the main branch, a situation like the one above would trigger FeatureLibError("Failed to compute deltas for variable scalar") because constructing the VariationModel throws "Location must be unique". Maybe that's what we actually want here, instead of silently overwriting.
In your code, constructing the VariationModel doesn't fail like that with duplicate locations, because you first call full_values = self.normalised_values(scalar) which deduplicates them, but then we get the assertion error in get_deltas_and_supports.

Copy link
Member

@anthrotype anthrotype Sep 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this passes on main but throws AssertionError: (2, 1) in your branch:

diff --git a/Tests/feaLib/builder_test.py b/Tests/feaLib/builder_test.py
index 05f645af3..5cc744b56 100644
--- a/Tests/feaLib/builder_test.py
+++ b/Tests/feaLib/builder_test.py
@@ -1308,6 +1308,22 @@ class BuilderTest(unittest.TestCase):
         assert self.get_region(var_region_axis_wght) == (0.0, 0.90625, 1.0)
         assert self.get_region(var_region_axis_wdth) == (0.0, 0.5, 1.0)
 
+    def test_variable_scalar_duplicate_location(self):
+        features = """
+            languagesystem DFLT dflt;
+
+            feature kern {
+                pos two <0 (wght=200:10 wght=200,wdth=100:20) 0 0>;
+            } kern;
+        """
+
+        font = self.make_mock_vf()
+
+        with self.assertRaisesRegex(
+            FeatureLibError, "Failed to compute deltas for variable scalar"
+        ):
+            addOpenTypeFeaturesFromString(font, features)
+
     def test_ligatureCaretByPos_variable_scalar(self):
         """Test that the `avar` table is consulted when normalizing user-space
         values."""

feel free to reuse the test if you like. I think it should still fail and with a more meaninful message like the one we had on main, rather than AssertionError.

@anthrotype
Copy link
Member

Previously, calculating an individual interpolated value did not account for avar mapping. This commit fixes this

not exactly clear to me where the bug was. would be nice to see a test that would fail on main and which would be fixed by this PR

Comment on lines -87 to -88
loc = dict(self._normalized_location(loc))
return self.model(model_cache, avar).interpolateFromMasters(loc, values)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suspect the bug was here, where it was passing non-avar-mapped normalized location to interpolateFromMasters?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that was it exactly

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The newly added test should fail on main in kind

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Although it uses the new designspace instantiation and builder, so will not run without some adapting to the old API)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

点击 这是indexloc提供的php浏览器服务,不要输入任何密码和下载