Repository: googlefonts/ufo2ft

Issue:
post table underlinePosition
The `postscriptUnderlinePosition` wasn't well specified in the UFO: it conflated the value for `CFF/Type1` and `post/CFF2`, where those values are actually different: `post/CFF2` value is the top of the underline, `CFF/Type1` is the middle of the underline.

The UFO spec got clarified to be clear that `postscriptUnerlinePosition` is `CFF/Type1' (https://github.com/unified-font-object/ufo-spec/issues/217).

The behavior for calculation from above is what the AFDKO has been doing, just need to update ufo2ft to follow suit.

I will PR a change (and tests) soon.

Comments:

Patch:
diff --git a/Lib/ufo2ft/constants.py b/Lib/ufo2ft/constants.py
index 242800a..eec1a9d 100644
--- a/Lib/ufo2ft/constants.py
+++ b/Lib/ufo2ft/constants.py
@@ -38,6 +38,7 @@ COLR_CLIP_BOXES_KEY = UFO2FT_PREFIX + "colrClipBoxes"
 OBJECT_LIBS_KEY = "public.objectLibs"
 OPENTYPE_CATEGORIES_KEY = "public.openTypeCategories"
 OPENTYPE_META_KEY = "public.openTypeMeta"
+OPENTYPE_POST_UNDERLINE_POSITION_KEY = "public.openTypePostUnderlinePosition"
 TRUETYPE_INSTRUCTIONS_KEY = "public.truetype.instructions"
 TRUETYPE_METRICS_KEY = "public.truetype.useMyMetrics"
 TRUETYPE_OVERLAP_KEY = "public.truetype.overlap"
diff --git a/Lib/ufo2ft/outlineCompiler.py b/Lib/ufo2ft/outlineCompiler.py
index 72b8328..14b7bfe 100644
--- a/Lib/ufo2ft/outlineCompiler.py
+++ b/Lib/ufo2ft/outlineCompiler.py
@@ -31,6 +31,7 @@ from ufo2ft.constants import (
     COLOR_PALETTES_KEY,
     COLR_CLIP_BOXES_KEY,
     OPENTYPE_META_KEY,
+    OPENTYPE_POST_UNDERLINE_POSITION_KEY,
     UNICODE_VARIATION_SEQUENCES_KEY,
 )
 from ufo2ft.errors import InvalidFontData
@@ -928,9 +929,12 @@ class BaseOutlineCompiler:
         italicAngle = float(getAttrWithFallback(font.info, "italicAngle"))
         post.italicAngle = italicAngle
         # underline
-        underlinePosition = getAttrWithFallback(
-            font.info, "postscriptUnderlinePosition"
-        )
+        if OPENTYPE_POST_UNDERLINE_POSITION_KEY in font.lib:
+            underlinePosition = font.lib[OPENTYPE_POST_UNDERLINE_POSITION_KEY]
+        else:
+            underlinePosition = getAttrWithFallback(
+                font.info, "postscriptUnderlinePosition"
+            )
         post.underlinePosition = otRound(underlinePosition)
         underlineThickness = getAttrWithFallback(
             font.info, "postscriptUnderlineThickness"
@@ -1326,7 +1330,16 @@ class OutlineOTFCompiler(BaseOutlineCompiler):
         # populate various numbers
         topDict.isFixedPitch = int(getAttrWithFallback(info, "postscriptIsFixedPitch"))
         topDict.ItalicAngle = float(getAttrWithFallback(info, "italicAngle"))
-        underlinePosition = getAttrWithFallback(info, "postscriptUnderlinePosition")
+        if (
+            OPENTYPE_POST_UNDERLINE_POSITION_KEY in self.ufo.lib
+            and info.postscriptUnderlinePosition is None
+        ):
+            underlinePosition = (
+                self.ufo.lib[OPENTYPE_POST_UNDERLINE_POSITION_KEY]
+                - getAttrWithFallback(info, "postscriptUnderlineThickness") / 2
+            )
+        else:
+            underlinePosition = getAttrWithFallback(info, "postscriptUnderlinePosition")
         topDict.UnderlinePosition = otRound(underlinePosition)
         underlineThickness = getAttrWithFallback(info, "postscriptUnderlineThickness")
         topDict.UnderlineThickness = otRound(underlineThickness)


Test patch:
diff --git a/tests/fontInfoData_test.py b/tests/fontInfoData_test.py
index 5e8bdd2..d594e77 100644
--- a/tests/fontInfoData_test.py
+++ b/tests/fontInfoData_test.py
@@ -185,6 +185,12 @@ class GetAttrWithFallbackTest:
         assert getAttrWithFallback(info, "xHeight") == 1024
         assert getAttrWithFallback(info, "descender") == -410
 
+    def test_underline_position(self, info):
+        assert getAttrWithFallback(info, "postscriptUnderlinePosition") == -75
+
+        info.postscriptUnderlinePosition = -485
+        assert getAttrWithFallback(info, "postscriptUnderlinePosition") == -485
+
 
 class PostscriptBlueScaleFallbackTest:
     def test_without_blue_zones(self, info):
diff --git a/tests/outlineCompiler_test.py b/tests/outlineCompiler_test.py
index 2bbf025..86a02f8 100644
--- a/tests/outlineCompiler_test.py
+++ b/tests/outlineCompiler_test.py
@@ -17,6 +17,7 @@ from ufo2ft import (
 )
 from ufo2ft.constants import (
     GLYPHS_DONT_USE_PRODUCTION_NAMES,
+    OPENTYPE_POST_UNDERLINE_POSITION_KEY,
     SPARSE_OTF_MASTER_TABLES,
     SPARSE_TTF_MASTER_TABLES,
     USE_PRODUCTION_NAMES,
@@ -262,6 +263,19 @@ class OutlineTTFCompilerTest:
         actual = compiler.otf["name"].getName(1, 3, 1, 1033).string
         assert actual == "Custom Name for Windows"
 
+    def test_post_underline_without_public_key(self, testufo):
+        compiler = OutlineTTFCompiler(testufo)
+        compiler.compile()
+        actual = compiler.otf["post"].underlinePosition
+        assert actual == -200
+
+    def test_post_underline_with_public_key(self, testufo):
+        testufo.lib[OPENTYPE_POST_UNDERLINE_POSITION_KEY] = -485
+        compiler = OutlineTTFCompiler(testufo)
+        compiler.compile()
+        actual = compiler.otf["post"].underlinePosition
+        assert actual == -485
+
 
 class OutlineOTFCompilerTest:
     def test_setupTable_CFF_all_blues_defined(self, testufo):
@@ -596,6 +610,81 @@ class OutlineOTFCompilerTest:
         assert private.defaultWidthX == 500
         assert private.nominalWidthX == 0
 
+    def test_underline_without_public_key(self, testufo):
+        # Test with no lib key
+        compiler = OutlineOTFCompiler(testufo)
+        compiler.compile()
+
+        post = compiler.otf["post"].underlinePosition
+
+        cff = compiler.otf["CFF "].cff
+        cff_underline = cff[list(cff.keys())[0]].UnderlinePosition
+
+        assert post == -200
+        assert cff_underline == -200
+
+    def test_underline_with_public_key(self, testufo):
+        # Test with a lib key and postscriptUnderlinePosition
+        testufo.lib[OPENTYPE_POST_UNDERLINE_POSITION_KEY] = -485
+        testufo.info.postscriptUnderlinePosition = -42
+        compiler = OutlineOTFCompiler(testufo)
+        compiler.compile()
+
+        post = compiler.otf["post"].underlinePosition
+
+        cff = compiler.otf["CFF "].cff
+        cff_underline = cff[list(cff.keys())[0]].UnderlinePosition
+
+        assert post == -485
+        assert cff_underline == -42
+
+    def test_underline_with_public_key_and_no_psPosition(self, testufo):
+        # Test with a lib key and no postscriptUnderlinePosition
+        testufo.lib[OPENTYPE_POST_UNDERLINE_POSITION_KEY] = -485
+        testufo.info.postscriptUnderlinePosition = None
+        testufo.info.postscriptUnderlineThickness = 100
+        compiler = OutlineOTFCompiler(testufo)
+        compiler.compile()
+
+        post = compiler.otf["post"].underlinePosition
+
+        cff = compiler.otf["CFF "].cff
+        cff_underline = cff[list(cff.keys())[0]].UnderlinePosition
+
+        assert post == -485
+        assert cff_underline == -535
+
+    def test_underline_with_no_public_key_and_no_psPosition(self, testufo):
+        compiler = OutlineOTFCompiler(testufo)
+        compiler.compile()
+
+        post = compiler.otf["post"].underlinePosition
+
+        cff = compiler.otf["CFF "].cff
+        cff_underline = cff[list(cff.keys())[0]].UnderlinePosition
+
+        # Note: This is actually incorrect according to the post/cff
+        # spec, but it is how UFO3 has things defined, and is expected
+        # current behavior.
+        assert post == -200
+        assert cff_underline == -200
+
+    def test_underline_ps_rounding(self, testufo):
+        # Test rounding
+        testufo.lib[OPENTYPE_POST_UNDERLINE_POSITION_KEY] = -485
+        testufo.info.postscriptUnderlinePosition = None
+        testufo.info.postscriptUnderlineThickness = 43
+        compiler = OutlineOTFCompiler(testufo)
+        compiler.compile()
+
+        post = compiler.otf["post"].underlinePosition
+
+        cff = compiler.otf["CFF "].cff
+        cff_underline = cff[list(cff.keys())[0]].UnderlinePosition
+
+        assert post == -485
+        assert cff_underline == -506
+
 
 class GlyphOrderTest:
     def test_compile_original_glyph_order(self, testufo):