Repository: fonttools/fonttools

Issue:
[T2CharStringPen] Rounding coordinates?
Currently, [T2CharStringPen](https://github.com/fonttools/fonttools/blob/fcd697a328b439165b3e9d04904eb73f065b6838/Lib/fontTools/pens/t2CharStringPen.py#L78) always rounds glyph coordinates and casts to int, but it does not seem a good idea. I think we should remove this behavior, or make it optional.

cf. TTGlyphPen (#526)


Discussion:
Yes. It was like that in the original ufo2fdk package by @typesupply.

I agree we should make the rounding optional, but am not sure if we should also change the current default, as others might be relying on that behaviour.

(Although that's not a big deal if we communicate it in the changelog and bump the version).
@behdad shall I make rounding optional in T2CharStringPen, or keep rounding on by default for backward compatibility, and make non-rounding optional?
> @behdad shall I make rounding optional in T2CharStringPen, or keep rounding on by default for backward compatibility, and make non-rounding optional?

This is relevant in the context of:

https://github.com/googlei18n/ufo2ft/issues/24

While having option to not round is useful in theory, my gut feeling is that a tolerance parameter is more useful.  Integers take 1 or 2 bytes for most common numbers to encode.  A float takes five.  So we should not blindly round-all or not-round-all numbers.  Something like a tolerance allows for a more granular encoding.   tolerance=0 results in no rounding.  tolerance>=.5 means rounding.  Numbers in between result in some rounding and not others.

The reason I didn't implement this before is that I'm not sure if that's a useful, or best, heuristic.  For example, if a control-point vector is large, say, has a length > 100 units, then rounding never makes a visible difference.  Whereas if its length is really short, then rounding might be more visible, with very thin fonts.  That's why I wanted to get Read's test font (which he sent me) and then experiment and find a better heuristic.  The theory I just sketched suggests that rounding or not should also be dependent on the absolute length of the vector or number at hand, not just the distance to closest integer.

Anyway, that's why I have not fixed that other issue yet, because I think some in-depth research is needed to come up with good defaults that don't bloat fonts but improve accuracy for most common cases.


It might be useful to consider an option for rounding some categories of points like corners, anchors, components, vertical and horizontal lines, but allow more resolution for points where it matters like angled tangent points, short control points and so on. But I don't know if that is an option in t2. 
I'm not sure all this is a job for fonttools. In general, fonttools doesn't have the information needed to decide what to round and what not. To me, that's clearly font editor territory. So in that sense, a "don't round" option may be good enough.

A tolerance-rounding mechanism may make sense, too, though, so that at least values like 10.0001 can be easily avoided. Unless we decide that's also the responsibility of the font editor.

Resolving patch:
diff --git a/Lib/fontTools/pens/t2CharStringPen.py b/Lib/fontTools/pens/t2CharStringPen.py
--- a/Lib/fontTools/pens/t2CharStringPen.py
+++ b/Lib/fontTools/pens/t2CharStringPen.py
@@ -9,15 +9,6 @@
 from fontTools.pens.basePen import BasePen
 
 
-def roundInt(v):
-    return int(round(v))
-
-
-def roundIntPoint(point):
-    x, y = point
-    return roundInt(x), roundInt(y)
-
-
 class RelativeCoordinatePen(BasePen):
 
     def __init__(self, glyphSet):
@@ -75,20 +66,45 @@ def _relativeCurveToOne(self, pt1, pt2, pt3):
         raise NotImplementedError
 
 
+def makeRoundFunc(tolerance):
+    if tolerance < 0:
+        raise ValueError("Rounding tolerance must be positive")
+
+    def _round(number):
+        if tolerance == 0:
+            return number  # no-op
+        rounded = round(number)
+        # return rounded integer if the tolerance >= 0.5, or if the absolute
+        # difference between the original float and the rounded integer is
+        # within the tolerance
+        if tolerance >= .5 or abs(rounded - number) <= tolerance:
+            return rounded
+        else:
+            # else return the value un-rounded
+            return number
+
+    def roundPoint(point):
+        x, y = point
+        return _round(x), _round(y)
+
+    return roundPoint
+
+
 class T2CharStringPen(RelativeCoordinatePen):
 
-    def __init__(self, width, glyphSet):
+    def __init__(self, width, glyphSet, roundTolerance=0.5):
         RelativeCoordinatePen.__init__(self, glyphSet)
+        self.roundPoint = makeRoundFunc(roundTolerance)
         self._heldMove = None
         self._program = []
         if width is not None:
-            self._program.append(roundInt(width))
+            self._program.append(round(width))
 
     def _moveTo(self, pt):
-        RelativeCoordinatePen._moveTo(self, roundIntPoint(pt))
+        RelativeCoordinatePen._moveTo(self, self.roundPoint(pt))
 
     def _relativeMoveTo(self, pt):
-        pt = roundIntPoint(pt)
+        pt = self.roundPoint(pt)
         x, y = pt
         self._heldMove = [x, y, "rmoveto"]
 
@@ -98,22 +114,25 @@ def _storeHeldMove(self):
             self._heldMove = None
 
     def _lineTo(self, pt):
-        RelativeCoordinatePen._lineTo(self, roundIntPoint(pt))
+        RelativeCoordinatePen._lineTo(self, self.roundPoint(pt))
 
     def _relativeLineTo(self, pt):
         self._storeHeldMove()
-        pt = roundIntPoint(pt)
+        pt = self.roundPoint(pt)
         x, y = pt
         self._program.extend([x, y, "rlineto"])
 
     def _curveToOne(self, pt1, pt2, pt3):
-        RelativeCoordinatePen._curveToOne(self, roundIntPoint(pt1), roundIntPoint(pt2), roundIntPoint(pt3))
+        RelativeCoordinatePen._curveToOne(self,
+                                          self.roundPoint(pt1),
+                                          self.roundPoint(pt2),
+                                          self.roundPoint(pt3))
 
     def _relativeCurveToOne(self, pt1, pt2, pt3):
         self._storeHeldMove()
-        pt1 = roundIntPoint(pt1)
-        pt2 = roundIntPoint(pt2)
-        pt3 = roundIntPoint(pt3)
+        pt1 = self.roundPoint(pt1)
+        pt2 = self.roundPoint(pt2)
+        pt3 = self.roundPoint(pt3)
         x1, y1 = pt1
         x2, y2 = pt2
         x3, y3 = pt3
@@ -127,5 +146,6 @@ def _endPath(self):
 
     def getCharString(self, private=None, globalSubrs=None):
         program = self._program + ["endchar"]
-        charString = T2CharString(program=program, private=private, globalSubrs=globalSubrs)
+        charString = T2CharString(
+            program=program, private=private, globalSubrs=globalSubrs)
         return charString


Test patch:
diff --git a/Lib/fontTools/pens/t2CharStringPen_test.py b/Lib/fontTools/pens/t2CharStringPen_test.py
new file mode 100644
--- /dev/null
+++ b/Lib/fontTools/pens/t2CharStringPen_test.py
@@ -0,0 +1,125 @@
+from __future__ import print_function, division, absolute_import
+from fontTools.misc.py23 import *
+from fontTools.pens.t2CharStringPen import T2CharStringPen
+import unittest
+
+
+class T2CharStringPenTest(unittest.TestCase):
+
+    def __init__(self, methodName):
+        unittest.TestCase.__init__(self, methodName)
+        # Python 3 renamed assertRaisesRegexp to assertRaisesRegex,
+        # and fires deprecation warnings if a program uses the old name.
+        if not hasattr(self, "assertRaisesRegex"):
+            self.assertRaisesRegex = self.assertRaisesRegexp
+
+    def assertAlmostEqualProgram(self, expected, actual):
+        self.assertEqual(len(expected), len(actual))
+        for i1, i2 in zip(expected, actual):
+            if isinstance(i1, basestring):
+                self.assertIsInstance(i2, basestring)
+                self.assertEqual(i1, i2)
+            else:
+                self.assertAlmostEqual(i1, i2)
+
+    def test_draw_lines(self):
+        pen = T2CharStringPen(100, {})
+        pen.moveTo((0, 0))
+        pen.lineTo((10, 0))
+        pen.lineTo((10, 10))
+        pen.lineTo((0, 10))
+        pen.closePath()  # no-op
+        charstring = pen.getCharString(None, None)
+
+        self.assertEqual(
+            [100,
+             0, 0, 'rmoveto',
+             10, 0, 'rlineto',
+             0, 10, 'rlineto',
+             -10, 0, 'rlineto',
+             'endchar'],
+            charstring.program)
+
+    def test_draw_curves(self):
+        pen = T2CharStringPen(100, {})
+        pen.moveTo((0, 0))
+        pen.curveTo((10, 0), (20, 10), (20, 20))
+        pen.curveTo((20, 30), (10, 40), (0, 40))
+        pen.endPath()  # no-op
+        charstring = pen.getCharString(None, None)
+
+        self.assertEqual(
+            [100,
+             0, 0, 'rmoveto',
+             10, 0, 10, 10, 0, 10, 'rrcurveto',
+             0, 10, -10, 10, -10, 0, 'rrcurveto',
+             'endchar'],
+            charstring.program)
+
+    def test_default_width(self):
+        pen = T2CharStringPen(None, {})
+        charstring = pen.getCharString(None, None)
+        self.assertEqual(['endchar'], charstring.program)
+
+    def test_no_round(self):
+        pen = T2CharStringPen(100.1, {}, roundTolerance=0.0)
+        pen.moveTo((0, 0))
+        pen.curveTo((10.1, 0.1), (19.9, 9.9), (20.49, 20.49))
+        pen.curveTo((20.49, 30.49), (9.9, 39.9), (0.1, 40.1))
+        pen.closePath()
+        charstring = pen.getCharString(None, None)
+
+        self.assertAlmostEqualProgram(
+            [100,  # we always round the advance width
+             0, 0, 'rmoveto',
+             10.1, 0.1, 9.8, 9.8, 0.59, 10.59, 'rrcurveto',
+             0, 10, -10.59, 9.41, -9.8, 0.2, 'rrcurveto',
+             'endchar'],
+            charstring.program)
+
+    def test_round_all(self):
+        pen = T2CharStringPen(100.1, {}, roundTolerance=0.5)
+        pen.moveTo((0, 0))
+        pen.curveTo((10.1, 0.1), (19.9, 9.9), (20.49, 20.49))
+        pen.curveTo((20.49, 30.49), (9.9, 39.9), (0.1, 40.1))
+        pen.closePath()
+        charstring = pen.getCharString(None, None)
+
+        self.assertEqual(
+            [100,
+             0, 0, 'rmoveto',
+             10, 0, 10, 10, 0, 10, 'rrcurveto',
+             0, 10, -10, 10, -10, 0, 'rrcurveto',
+             'endchar'],
+            charstring.program)
+
+    def test_round_some(self):
+        pen = T2CharStringPen(100, {}, roundTolerance=0.2)
+        pen.moveTo((0, 0))
+        # the following two are rounded as within the tolerance
+        pen.lineTo((10.1, 0.1))
+        pen.lineTo((19.9, 9.9))
+        # this one is not rounded as it exceeds the tolerance
+        pen.lineTo((20.49, 20.49))
+        pen.closePath()
+        charstring = pen.getCharString(None, None)
+
+        self.assertAlmostEqualProgram(
+            [100,
+             0, 0, 'rmoveto',
+             10, 0, 'rlineto',
+             10, 10, 'rlineto',
+             0.49, 10.49, 'rlineto',
+             'endchar'],
+            charstring.program)
+
+    def test_invalid_tolerance(self):
+        self.assertRaisesRegex(
+            ValueError,
+            "Rounding tolerance must be positive",
+            T2CharStringPen, None, {}, roundTolerance=-0.1)
+
+
+if __name__ == '__main__':
+    import sys
+    sys.exit(unittest.main())