Repository: mathandy/svgpathtools

Issue:
length() fails on certain quadratic curves (svg.path#61)
This code fails to the same bug in regebro/svg.path#61

You cannot calculate the length of the following paths without throwing an error:

- M 425.781 446.289 Q 410.40000000000003 373.047 410.4 373.047
- M 639.648 568.115 Q 606.6890000000001 507.568 606.689 507.568
- M 288.818 616.699 Q 301.025 547.3629999999999 301.025 547.363
- M 339.927 706.25 Q 243.92700000000002 806.25 243.927 806.25
- M 539.795 702.637 Q 548.0959999999999 803.4669999999999 548.096 803.467
- M 537.815 555.042 Q 570.1680000000001 499.1600000000001 570.168 499.16
- M 615.297 470.503 Q 538.797 694.5029999999999 538.797 694.503

Because the code in the quad arc performs a check for the quasi-linear against 1e-12 and these are not linear enough to fall into those prechecks, but are too linear to allow the function to work and they end up dividing by zero anyway.

QuadraticBezierCurve.length()  https://github.com/mathandy/svgpathtools/blob/master/svgpathtools/path.py#L812

```python
        if abs(a) < 1e-12:
            s = abs(b)*(t1 - t0)
        elif abs(a_dot_b + abs(a)*abs(b)) < 1e-12:
            ...
```

You need to try the main block of code, and perform these functions on fail rather than beforehand, if it throws a divide by zero or valueerror (domain) accordingly.


Comments:

Patch:
diff --git a/svgpathtools/path.py b/svgpathtools/path.py
index 54ddac4..9f6ea3f 100644
--- a/svgpathtools/path.py
+++ b/svgpathtools/path.py
@@ -18,7 +18,7 @@ from itertools import tee
 # in order to encourage code that generalizes to vector inputs
 from numpy import sqrt, cos, sin, tan, arccos as acos, arcsin as asin, \
     degrees, radians, log, pi, ceil
-from numpy import exp, sqrt as csqrt, angle as phase
+from numpy import exp, sqrt as csqrt, angle as phase, isnan
 
 try:
     from scipy.integrate import quad
@@ -898,31 +898,30 @@ class QuadraticBezier(object):
 
         if abs(a) < 1e-12:
             s = abs(b)*(t1 - t0)
-        elif abs(a_dot_b + abs(a)*abs(b)) < 1e-12:
-            tstar = abs(b)/(2*abs(a))
-            if t1 < tstar:
-                return abs(a)*(t0**2 - t1**2) - abs(b)*(t0 - t1)
-            elif tstar < t0:
-                return abs(a)*(t1**2 - t0**2) - abs(b)*(t1 - t0)
-            else:
-                return abs(a)*(t1**2 + t0**2) - abs(b)*(t1 + t0) + \
-                    abs(b)**2/(2*abs(a))
         else:
-            c2 = 4*(a.real**2 + a.imag**2)
-            c1 = 4*a_dot_b
-            c0 = b.real**2 + b.imag**2
-
-            beta = c1/(2*c2)
-            gamma = c0/c2 - beta**2
-
-            dq1_mag = sqrt(c2*t1**2 + c1*t1 + c0)
-            dq0_mag = sqrt(c2*t0**2 + c1*t0 + c0)
-            logarand = (sqrt(c2)*(t1 + beta) + dq1_mag) / \
-                       (sqrt(c2)*(t0 + beta) + dq0_mag)
-
-            s = (t1 + beta)*dq1_mag - (t0 + beta)*dq0_mag + \
-                gamma*sqrt(c2)*log(logarand)
+            c2 = 4 * (a.real ** 2 + a.imag ** 2)
+            c1 = 4 * a_dot_b
+            c0 = b.real ** 2 + b.imag ** 2
+
+            beta = c1 / (2 * c2)
+            gamma = c0 / c2 - beta ** 2
+
+            dq1_mag = sqrt(c2 * t1 ** 2 + c1 * t1 + c0)
+            dq0_mag = sqrt(c2 * t0 ** 2 + c1 * t0 + c0)
+            logarand = (sqrt(c2) * (t1 + beta) + dq1_mag) / \
+                       (sqrt(c2) * (t0 + beta) + dq0_mag)
+            s = (t1 + beta) * dq1_mag - (t0 + beta) * dq0_mag + \
+                gamma * sqrt(c2) * log(logarand)
             s /= 2
+            if isnan(s):
+                tstar = abs(b) / (2 * abs(a))
+                if t1 < tstar:
+                    return abs(a) * (t0 ** 2 - t1 ** 2) - abs(b) * (t0 - t1)
+                elif tstar < t0:
+                    return abs(a) * (t1 ** 2 - t0 ** 2) - abs(b) * (t1 - t0)
+                else:
+                    return abs(a) * (t1 ** 2 + t0 ** 2) - abs(b) * (t1 + t0) + \
+                           abs(b) ** 2 / (2 * abs(a))
 
         if t0 == 1 and t1 == 0:
             self._length_info['length'] = s
@@ -2445,7 +2444,10 @@ class Path(MutableSequence):
         lengths = [each.length(error=error, min_depth=min_depth) for each in
                    self._segments]
         self._length = sum(lengths)
-        self._lengths = [each/self._length for each in lengths]
+        if self._length == 0:
+            self._lengths = lengths  # all lengths are 0.
+        else:
+            self._lengths = [each / self._length for each in lengths]
 
     def point(self, pos):
 
@@ -2522,7 +2524,10 @@ class Path(MutableSequence):
         return self.start == self.end
 
     def _is_closable(self):
-        end = self[-1].end
+        try:
+            end = self[-1].end
+        except IndexError:
+            return True
         for segment in self:
             if segment.start == end:
                 return True
@@ -2574,7 +2579,8 @@ class Path(MutableSequence):
         """Returns a path d-string for the path object.
         For an explanation of useSandT and use_closed_attrib, see the
         compatibility notes in the README."""
-    
+        if len(self) == 0:
+            return ''
         if use_closed_attrib:
             self_closed = self.iscontinuous() and self.isclosed()
             if self_closed:
@@ -2866,8 +2872,7 @@ class Path(MutableSequence):
         # redundant intersection.  This code block checks for and removes said
         # redundancies.
         if intersection_list:
-            pts = [seg1.point(_t1)
-                   for _T1, _seg1, _t1 in list(zip(*intersection_list))[0]]
+            pts = [_seg1.point(_t1) for _T1, _seg1, _t1 in list(zip(*intersection_list))[0]]
             indices2remove = []
             for ind1 in range(len(pts)):
                 for ind2 in range(ind1 + 1, len(pts)):


Test patch:
diff --git a/test/test_path.py b/test/test_path.py
index 3769bd0..5b7d448 100644
--- a/test/test_path.py
+++ b/test/test_path.py
@@ -687,7 +687,6 @@ class ArcTest(unittest.TestCase):
             self.assertAlmostEqual(d,0.0, delta=2)
 
 
-
 class TestPath(unittest.TestCase):
 
     def test_circle(self):
@@ -1660,7 +1659,6 @@ class Test_intersect(unittest.TestCase):
         assert_intersections(a0, a1, intersections, 0)
 
 
-
 class TestPathTools(unittest.TestCase):
     # moved from test_pathtools.py
 
@@ -1950,5 +1948,50 @@ class TestPathTools(unittest.TestCase):
         self.assertTrue(enclosing_shape.is_contained_by(larger_shape))
 
 
+class TestPathBugs(unittest.TestCase):
+
+    def test_issue_113(self):
+        """
+        Tests against issue regebro/svg.path#61 mathandy/svgpathtools#113
+        """
+        p = Path('M 206.5,525 Q 162.5,583 162.5,583')
+        self.assertAlmostEqual(p.length(), 72.80109889280519)
+        p = Path('M 425.781 446.289 Q 410.40000000000003 373.047 410.4 373.047')
+        self.assertAlmostEqual(p.length(), 74.83959997888816)
+        p = Path('M 639.648 568.115 Q 606.6890000000001 507.568 606.689 507.568')
+        self.assertAlmostEqual(p.length(), 68.93645544992873)
+        p = Path('M 288.818 616.699 Q 301.025 547.3629999999999 301.025 547.363')
+        self.assertAlmostEqual(p.length(), 70.40235610403947)
+        p = Path('M 339.927 706.25 Q 243.92700000000002 806.25 243.927 806.25')
+        self.assertAlmostEqual(p.length(), 138.6217876093077)
+        p = Path('M 539.795 702.637 Q 548.0959999999999 803.4669999999999 548.096 803.467')
+        self.assertAlmostEqual(p.length(), 101.17111989594662)
+        p = Path('M 537.815 555.042 Q 570.1680000000001 499.1600000000001 570.168 499.16')
+        self.assertAlmostEqual(p.length(), 64.57177814649368)
+        p = Path('M 615.297 470.503 Q 538.797 694.5029999999999 538.797 694.503')
+        self.assertAlmostEqual(p.length(), 236.70287281737836)
+
+    def test_issue_71(self):
+        p = Path("M327 468z")
+        m = p.closed
+        q = p.d()  # Failing to Crash is good.
+
+    def test_issue_95(self):
+        """
+        Corrects:
+        https://github.com/mathandy/svgpathtools/issues/95
+        """
+        p = Path('M261 166 L261 166')
+        self.assertEqual(p.length(), 0)
+
+    def test_issue_94(self):
+        # clipping rectangle
+        p1 = Path('M0.0 0.0 L27.84765625 0.0 L27.84765625 242.6669922 L0.0 242.6669922 z')
+        # clipping rectangle
+        p2 = Path('M166.8359375,235.5478516c0,3.7773438-3.0859375,6.8691406-6.8701172,6.8691406H7.1108398c-3.7749023,0-6.8608398-3.0917969-6.8608398-6.8691406V7.1201172C0.25,3.3427734,3.3359375,0.25,7.1108398,0.25h152.8549805c3.7841797,0,6.8701172,3.0927734,6.8701172,6.8701172v228.4277344z')
+        self.assertEqual(len(p1.intersect(p2)), len(p2.intersect(p1)))
+
+
+
 if __name__ == '__main__':
     unittest.main()