Repository: fonttools/fonttools

Issue:
[ttGlyphPen] decompose components if transform overflows F2Dot14
https://github.com/googlei18n/ufo2ft/issues/217

The UFO GLIF spec allows any numbers for xScale, xyScale, yxScale, yScale, xOffset, yOffset, however the OpenType glyf spec uses F2Dot14 numbers, which are encoded as a signed 16-bit integer and therefore can only contain values from -32768 (-0x8000, or -2.0) to +32767 included (0x7FFF, or +1.99993896484375...).

We can't let the `struct.error` propagate.

I think we have to handle the case of +2.0 specially, and treat it as if it were 1.99993896484375. By doing that we can support truetype component transforms in the range -2.0 to +2.0 (inclusive), for the sake of simplicity.

Then, we also need to have the ttGlyphPen decompose the components if their transform values are less than -2.0 or they are greater than +2.0 (not greater and equal), as these can't fit in the TrueType glyf table.




Discussion:

Resolving patch:
diff --git a/Lib/fontTools/pens/ttGlyphPen.py b/Lib/fontTools/pens/ttGlyphPen.py
--- a/Lib/fontTools/pens/ttGlyphPen.py
+++ b/Lib/fontTools/pens/ttGlyphPen.py
@@ -12,11 +12,32 @@
 __all__ = ["TTGlyphPen"]
 
 
-class TTGlyphPen(AbstractPen):
-    """Pen used for drawing to a TrueType glyph."""
+# the max value that can still fit in an F2Dot14:
+# 1.99993896484375
+MAX_F2DOT14 = 0x7FFF / (1 << 14)
+
 
-    def __init__(self, glyphSet):
+class TTGlyphPen(AbstractPen):
+    """Pen used for drawing to a TrueType glyph.
+
+    If `handleOverflowingTransforms` is True, the components' transform values
+    are checked that they don't overflow the limits of a F2Dot14 number:
+    -2.0 <= v < +2.0. If any transform value exceeds these, the composite
+    glyph is decomposed.
+    An exception to this rule is done for values that are very close to +2.0
+    (both for consistency with the -2.0 case, and for the relative frequency
+    these occur in real fonts). When almost +2.0 values occur (and all other
+    values are within the range -2.0 <= x <= +2.0), they are clamped to the
+    maximum positive value that can still be encoded as an F2Dot14: i.e.
+    1.99993896484375.
+    If False, no check is done and all components are translated unmodified
+    into the glyf table, followed by an inevitable `struct.error` once an
+    attempt is made to compile them.
+    """
+
+    def __init__(self, glyphSet, handleOverflowingTransforms=True):
         self.glyphSet = glyphSet
+        self.handleOverflowingTransforms = handleOverflowingTransforms
         self.init()
 
     def init(self):
@@ -82,19 +103,33 @@ def addComponent(self, glyphName, transformation):
     def glyph(self, componentFlags=0x4):
         assert self._isClosed(), "Didn't close last contour."
 
+        if self.handleOverflowingTransforms:
+            # we can't encode transform values > 2 or < -2 in F2Dot14,
+            # so we must decompose the glyph if any transform exceeds these
+            overflowing = any(s > 2 or s < -2
+                              for (glyphName, transformation) in self.components
+                              for s in transformation[:4])
+
         components = []
         for glyphName, transformation in self.components:
-            if self.points:
-                # can't have both, so decompose the glyph
+            if (self.points or
+                    (self.handleOverflowingTransforms and overflowing)):
+                # can't have both coordinates and components, so decompose
                 tpen = TransformPen(self, transformation)
                 self.glyphSet[glyphName].draw(tpen)
                 continue
 
             component = GlyphComponent()
             component.glyphName = glyphName
-            if transformation[:4] != (1, 0, 0, 1):
-                component.transform = (transformation[:2], transformation[2:4])
             component.x, component.y = transformation[4:]
+            transformation = transformation[:4]
+            if transformation != (1, 0, 0, 1):
+                if (self.handleOverflowingTransforms and
+                        any(MAX_F2DOT14 < s <= 2 for s in transformation)):
+                    # clamp values ~= +2.0 so we can keep the component
+                    transformation = tuple(MAX_F2DOT14 if MAX_F2DOT14 < s <= 2
+                                           else s for s in transformation)
+                component.transform = (transformation[:2], transformation[2:])
             component.flags = componentFlags
             components.append(component)
 


Test patch:
diff --git a/Tests/feaLib/lexer_test.py b/Tests/feaLib/lexer_test.py
--- a/Tests/feaLib/lexer_test.py
+++ b/Tests/feaLib/lexer_test.py
@@ -224,7 +224,8 @@ def test_include_relative_to_cwd(self):
             # directory to resolve relative include statements
             lexer = IncludingLexer(UnicodeIO("include(included.fea);"))
             files = set(loc[0] for _, _, loc in lexer)
-            self.assertIn(included.name, files)
+            expected = os.path.realpath(included.name)
+            self.assertIn(expected, files)
         finally:
             # remove temporary folder and restore previous working directory
             os.chdir(cwd)
diff --git a/Tests/pens/ttGlyphPen_test.py b/Tests/pens/ttGlyphPen_test.py
--- a/Tests/pens/ttGlyphPen_test.py
+++ b/Tests/pens/ttGlyphPen_test.py
@@ -3,12 +3,14 @@
 
 import os
 import unittest
+import struct
 
 from fontTools import ttLib
-from fontTools.pens.ttGlyphPen import TTGlyphPen
+from fontTools.misc.testTools import TestCase
+from fontTools.pens.ttGlyphPen import TTGlyphPen, MAX_F2DOT14
 
 
-class TTGlyphPenTest(unittest.TestCase):
+class TTGlyphPenTest(TestCase):
 
     def runEndToEnd(self, filename):
         font = ttLib.TTFont()
@@ -135,6 +137,108 @@ def test_keep_duplicate_end_point(self):
         self.assertEqual(len(pen.points), 5)
         self.assertEqual(pen.points[0], (0, 0))
 
+    def test_within_range_component_transform(self):
+        componentName = 'a'
+        glyphSet = {}
+        pen = TTGlyphPen(glyphSet)
+
+        pen.moveTo((0, 0))
+        pen.lineTo((0, 1))
+        pen.lineTo((1, 0))
+        pen.closePath()
+        glyphSet[componentName] = _TestGlyph(pen.glyph())
+
+        pen.addComponent(componentName, (1.5, 0, 0, 1, 0, 0))
+        pen.addComponent(componentName, (1, 0, 0, -1.5, 0, 0))
+        compositeGlyph = pen.glyph()
+
+        pen.addComponent(componentName, (1.5, 0, 0, 1, 0, 0))
+        pen.addComponent(componentName, (1, 0, 0, -1.5, 0, 0))
+        expectedGlyph = pen.glyph()
+
+        self.assertEqual(expectedGlyph, compositeGlyph)
+
+    def test_clamp_to_almost_2_component_transform(self):
+        componentName = 'a'
+        glyphSet = {}
+        pen = TTGlyphPen(glyphSet)
+
+        pen.moveTo((0, 0))
+        pen.lineTo((0, 1))
+        pen.lineTo((1, 0))
+        pen.closePath()
+        glyphSet[componentName] = _TestGlyph(pen.glyph())
+
+        pen.addComponent(componentName, (1.99999, 0, 0, 1, 0, 0))
+        pen.addComponent(componentName, (1, 2, 0, 1, 0, 0))
+        pen.addComponent(componentName, (1, 0, 2, 1, 0, 0))
+        pen.addComponent(componentName, (1, 0, 0, 2, 0, 0))
+        pen.addComponent(componentName, (-2, 0, 0, -2, 0, 0))
+        compositeGlyph = pen.glyph()
+
+        almost2 = MAX_F2DOT14  # 0b1.11111111111111
+        pen.addComponent(componentName, (almost2, 0, 0, 1, 0, 0))
+        pen.addComponent(componentName, (1, almost2, 0, 1, 0, 0))
+        pen.addComponent(componentName, (1, 0, almost2, 1, 0, 0))
+        pen.addComponent(componentName, (1, 0, 0, almost2, 0, 0))
+        pen.addComponent(componentName, (-2, 0, 0, -2, 0, 0))
+        expectedGlyph = pen.glyph()
+
+        self.assertEqual(expectedGlyph, compositeGlyph)
+
+    def test_out_of_range_transform_decomposed(self):
+        componentName = 'a'
+        glyphSet = {}
+        pen = TTGlyphPen(glyphSet)
+
+        pen.moveTo((0, 0))
+        pen.lineTo((0, 1))
+        pen.lineTo((1, 0))
+        pen.closePath()
+        glyphSet[componentName] = _TestGlyph(pen.glyph())
+
+        pen.addComponent(componentName, (3, 0, 0, 2, 0, 0))
+        pen.addComponent(componentName, (1, 0, 0, 1, -1, 2))
+        pen.addComponent(componentName, (2, 0, 0, -3, 0, 0))
+        compositeGlyph = pen.glyph()
+
+        pen.moveTo((0, 0))
+        pen.lineTo((0, 2))
+        pen.lineTo((3, 0))
+        pen.closePath()
+        pen.moveTo((-1, 2))
+        pen.lineTo((-1, 3))
+        pen.lineTo((0, 2))
+        pen.closePath()
+        pen.moveTo((0, 0))
+        pen.lineTo((0, -3))
+        pen.lineTo((2, 0))
+        pen.closePath()
+        expectedGlyph = pen.glyph()
+
+        self.assertEqual(expectedGlyph, compositeGlyph)
+
+    def test_no_handle_overflowing_transform(self):
+        componentName = 'a'
+        glyphSet = {}
+        pen = TTGlyphPen(glyphSet, handleOverflowingTransforms=False)
+
+        pen.moveTo((0, 0))
+        pen.lineTo((0, 1))
+        pen.lineTo((1, 0))
+        pen.closePath()
+        baseGlyph = pen.glyph()
+        glyphSet[componentName] = _TestGlyph(baseGlyph)
+
+        pen.addComponent(componentName, (3, 0, 0, 1, 0, 0))
+        compositeGlyph = pen.glyph()
+
+        self.assertEqual(compositeGlyph.components[0].transform,
+                         ((3, 0), (0, 1)))
+
+        with self.assertRaises(struct.error):
+            compositeGlyph.compile({'a': baseGlyph})
+
 
 class _TestGlyph(object):
     def __init__(self, glyph):