Repository: fonttools/fonttools

Issue:
[varLib.instancer] hhea metrics not matching typoMetrics when instancing VFs
We are using variable vertical metrics in a super-VF from which we are instantiating 45 different families (static and VFs) through `varLib.instancer`.  This allows us to proportionally adjust the metrics of the instantiated families based on the provided axis positions.

This is working well for the `OS/2.sTypo` metrics both in the static and variable fonts. However, the `hhea` metrics ~~in the variable font~~ are still using the default values instead of adjusting proportionally.


**Resources and steps needed to reproduce**

This is happening with: fonttools 4.42.1. and also after updating it to fonttools 4.43.1.

Recipe to one of the instantiated fonts
````
fonttools varLib.instancer path/to/orig-font SPED=25 YEXT=520 wght=100:400 slnt=18 -o path/to/instantiated-font
````

files
[varLib-test-fonts.zip](https://github.com/fonttools/fonttools/files/12864141/varLib-test-fonts.zip)






Discussion:
I'm not sure I understand this. In your example files, all statics have the same hhea metrics:

```
    <ascent value="1275"/>
    <descent value="-375"/>
    <lineGap value="0"/>
```

and the instantiated variable also has these same values. So that looks correct to me. Which metrics were you thinking of, and what values do you think they should have?

One thing to note is that the MVAR table does *not* specify a way to vary `hhea.ascent` and `hhea.descent`, only the OS/2 values, because Microsoft.
> One thing to note is that the MVAR table does not specify a way to vary hhea.ascent and hhea.descent, only the OS/2 values, because Microsoft.

https://learn.microsoft.com/en-us/typography/opentype/spec/mvar

I just checked the spec and was surpised that this is the case..
Note though that 'vasc' (equivalent for 'hasc' for vertical ascender) does mention vhea.ascent. I think the instancer could/should update hhea ascent/descent/lineGap when in the input font they do match the respective OS/2 metrics.
> I'm not sure I understand this. In your example files, all statics have the same hhea metrics:

Ugh, yes, then they are bad too.

> Which metrics were you thinking of, and what values do you think they should have?

To match the OS/2 ones.

```
    <sTypoAscender value="1452"/>
    <sTypoDescender value="-525"/>
    <sTypoLineGap value="0"/>
```

> I think the instancer could/should update hhea ascent/descent/lineGap when in the input font they do match the respective OS/2 metrics

This would be ideal 
> I think the instancer could/should update hhea ascent/descent/lineGap when in the input font they do match the respective OS/2 metrics

@anthrotype This would solve a clipping issue we are currently receiving in Word Mac. Do you think this could be adopted soon?
it should be easy to do, I'll do that now

Resolving patch:
diff --git a/Lib/fontTools/varLib/instancer/__init__.py b/Lib/fontTools/varLib/instancer/__init__.py
--- a/Lib/fontTools/varLib/instancer/__init__.py
+++ b/Lib/fontTools/varLib/instancer/__init__.py
@@ -105,6 +105,7 @@
 from fontTools.varLib.instancer import solver
 import collections
 import dataclasses
+from contextlib import contextmanager
 from copy import deepcopy
 from enum import IntEnum
 import logging
@@ -694,6 +695,43 @@ def setMvarDeltas(varfont, deltas):
             )
 
 
+@contextmanager
+def verticalMetricsKeptInSync(varfont):
+    """Ensure hhea vertical metrics stay in sync with OS/2 ones after instancing.
+
+    When applying MVAR deltas to the OS/2 table, if the ascender, descender and
+    line gap change but they were the same as the respective hhea metrics in the
+    original font, this context manager ensures that hhea metrcs also get updated
+    accordingly.
+    The MVAR spec only has tags for the OS/2 metrics, but it is common in fonts
+    to have the hhea metrics be equal to those for compat reasons.
+
+    https://learn.microsoft.com/en-us/typography/opentype/spec/mvar
+    https://googlefonts.github.io/gf-guide/metrics.html#7-hhea-and-typo-metrics-should-be-equal
+    https://github.com/fonttools/fonttools/issues/3297
+    """
+    current_os2_vmetrics = [
+        getattr(varfont["OS/2"], attr)
+        for attr in ("sTypoAscender", "sTypoDescender", "sTypoLineGap")
+    ]
+    metrics_are_synced = current_os2_vmetrics == [
+        getattr(varfont["hhea"], attr) for attr in ("ascender", "descender", "lineGap")
+    ]
+
+    yield metrics_are_synced
+
+    if metrics_are_synced:
+        new_os2_vmetrics = [
+            getattr(varfont["OS/2"], attr)
+            for attr in ("sTypoAscender", "sTypoDescender", "sTypoLineGap")
+        ]
+        if current_os2_vmetrics != new_os2_vmetrics:
+            for attr, value in zip(
+                ("ascender", "descender", "lineGap"), new_os2_vmetrics
+            ):
+                setattr(varfont["hhea"], attr, value)
+
+
 def instantiateMVAR(varfont, axisLimits):
     log.info("Instantiating MVAR table")
 
@@ -701,7 +739,9 @@ def instantiateMVAR(varfont, axisLimits):
     fvarAxes = varfont["fvar"].axes
     varStore = mvar.VarStore
     defaultDeltas = instantiateItemVariationStore(varStore, fvarAxes, axisLimits)
-    setMvarDeltas(varfont, defaultDeltas)
+
+    with verticalMetricsKeptInSync(varfont):
+        setMvarDeltas(varfont, defaultDeltas)
 
     if varStore.VarRegionList.Region:
         varIndexMapping = varStore.optimize()


Test patch:
diff --git a/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx b/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx
--- a/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx
+++ b/Tests/varLib/instancer/data/PartialInstancerTest-VF.ttx
@@ -108,9 +108,9 @@
     <fsSelection value="00000000 01000000"/>
     <usFirstCharIndex value="32"/>
     <usLastCharIndex value="8722"/>
-    <sTypoAscender value="800"/>
+    <sTypoAscender value="1000"/>
     <sTypoDescender value="-200"/>
-    <sTypoLineGap value="200"/>
+    <sTypoLineGap value="0"/>
     <usWinAscent value="1000"/>
     <usWinDescent value="200"/>
     <ulCodePageRange1 value="00000000 00000000 00000000 00000001"/>
@@ -687,6 +687,7 @@
         <!-- VarRegionCount=1 -->
         <VarRegionIndex index="0" value="0"/>
         <Item index="0" value="[30]"/>
+        <Item index="1" value="[100]"/>
       </VarData>
     </VarStore>
     <ValueRecord index="0">
@@ -705,6 +706,10 @@
       <ValueTag value="xhgt"/>
       <VarIdx value="65536"/>
     </ValueRecord>
+    <ValueRecord index="3">
+      <ValueTag value="hasc"/>
+      <VarIdx value="65537"/>
+    </ValueRecord>
   </MVAR>
 
   <STAT>
diff --git a/Tests/varLib/instancer/instancer_test.py b/Tests/varLib/instancer/instancer_test.py
--- a/Tests/varLib/instancer/instancer_test.py
+++ b/Tests/varLib/instancer/instancer_test.py
@@ -304,39 +304,69 @@ def test_pin_and_drop_axis(self, varfont, location, expected):
         assert len(mvar.VarStore.VarData) == 1
 
     @pytest.mark.parametrize(
-        "location, expected",
+        "location, expected, sync_vmetrics",
         [
             pytest.param(
                 {"wght": 1.0, "wdth": 0.0},
-                {"strs": 100, "undo": -200, "unds": 150},
+                {"strs": 100, "undo": -200, "unds": 150, "hasc": 1100},
+                True,
                 id="wght=1.0,wdth=0.0",
             ),
             pytest.param(
                 {"wght": 0.0, "wdth": -1.0},
-                {"strs": 20, "undo": -100, "unds": 50},
+                {"strs": 20, "undo": -100, "unds": 50, "hasc": 1000},
+                True,
                 id="wght=0.0,wdth=-1.0",
             ),
             pytest.param(
                 {"wght": 0.5, "wdth": -0.5},
-                {"strs": 55, "undo": -145, "unds": 95},
+                {"strs": 55, "undo": -145, "unds": 95, "hasc": 1050},
+                True,
                 id="wght=0.5,wdth=-0.5",
             ),
             pytest.param(
                 {"wght": 1.0, "wdth": -1.0},
-                {"strs": 50, "undo": -180, "unds": 130},
+                {"strs": 50, "undo": -180, "unds": 130, "hasc": 1100},
+                True,
                 id="wght=0.5,wdth=-0.5",
             ),
+            pytest.param(
+                {"wght": 1.0, "wdth": 0.0},
+                {"strs": 100, "undo": -200, "unds": 150, "hasc": 1100},
+                False,
+                id="wght=1.0,wdth=0.0,no_sync_vmetrics",
+            ),
         ],
     )
-    def test_full_instance(self, varfont, location, expected):
+    def test_full_instance(self, varfont, location, sync_vmetrics, expected):
         location = instancer.NormalizedAxisLimits(location)
 
+        # check vertical metrics are in sync before...
+        if sync_vmetrics:
+            assert varfont["OS/2"].sTypoAscender == varfont["hhea"].ascender
+            assert varfont["OS/2"].sTypoDescender == varfont["hhea"].descender
+            assert varfont["OS/2"].sTypoLineGap == varfont["hhea"].lineGap
+        else:
+            # force them not to be in sync
+            varfont["OS/2"].sTypoDescender -= 100
+            varfont["OS/2"].sTypoLineGap += 200
+
         instancer.instantiateMVAR(varfont, location)
 
         for mvar_tag, expected_value in expected.items():
             table_tag, item_name = MVAR_ENTRIES[mvar_tag]
             assert getattr(varfont[table_tag], item_name) == expected_value
 
+        # ... as well as after instancing, but only if they were already
+        # https://github.com/fonttools/fonttools/issues/3297
+        if sync_vmetrics:
+            assert varfont["OS/2"].sTypoAscender == varfont["hhea"].ascender
+            assert varfont["OS/2"].sTypoDescender == varfont["hhea"].descender
+            assert varfont["OS/2"].sTypoLineGap == varfont["hhea"].lineGap
+        else:
+            assert varfont["OS/2"].sTypoDescender != varfont["hhea"].descender
+            assert varfont["OS/2"].sTypoLineGap != varfont["hhea"].lineGap
+
         assert "MVAR" not in varfont