Skip to content

Commit 6ebb6b8

Browse files
authored
Merge pull request #66 from fonttools/oncurveless-path
support special TrueType paths with no on-curve points
2 parents 2e7c322 + ccab552 commit 6ebb6b8

File tree

2 files changed

+115
-3
lines changed

2 files changed

+115
-3
lines changed

src/python/pathops/_pathops.pyx

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,23 @@ from cpython.mem cimport PyMem_Malloc, PyMem_Free, PyMem_Realloc
4141
from libc.string cimport memset
4242
cimport cython
4343
import itertools
44+
import sys
45+
46+
47+
if sys.version_info[:2] < (3, 10):
48+
from itertools import tee
49+
50+
def pairwise(iterable):
51+
"""Return successive overlapping pairs taken from the input iterable.
52+
53+
Backported from Python 3.10.
54+
https://docs.python.org/3/library/itertools.html#itertools.pairwise
55+
"""
56+
a, b = tee(iterable)
57+
next(b, None)
58+
return zip(a, b)
59+
else:
60+
from itertools import pairwise
4461

4562

4663
cdef class PathOpsError(Exception):
@@ -138,6 +155,17 @@ def _format_hex_coords(floats):
138155
) + "\n"
139156

140157

158+
def triplewise(iterable):
159+
"""Return overlapping triplets from an iterable
160+
161+
E.g. triplewise('ABCDEFG') --> ABC BCD CDE DEF EFG
162+
163+
From: https://docs.python.org/3/library/itertools.html#itertools-recipes
164+
"""
165+
for (a, _), (b, c) in pairwise(pairwise(iterable)):
166+
yield a, b, c
167+
168+
141169
cdef class Path:
142170

143171
def __init__(self, other=None, fillType=None):
@@ -243,9 +271,8 @@ cdef class Path:
243271
cpdef draw(self, pen):
244272
cdef str method
245273
cdef tuple pts
246-
cdef SegmentPenIterator iterator = SegmentPenIterator(self)
247274

248-
for method, pts in iterator:
275+
for method, pts in self.segments:
249276
getattr(pen, method)(*pts)
250277

251278
def dump(self, cpp=False, as_hex=False):
@@ -617,7 +644,37 @@ cdef class Path:
617644

618645
@property
619646
def segments(self):
620-
return SegmentPenIterator(self)
647+
# We need to check for TrueType special quadratic closed spline made of
648+
# off-curve points only so that we can make the move point implied.
649+
# It's easier to do this in here than inside the SegmentPenIterator, as that
650+
# yields each segment one by one, whereas we want to sometimes *not* yield a
651+
# moveTo in very specific circumstances (i.e. the whole contour is a single
652+
# closed quadratic spline where all the on-curve points are midway between
653+
# consecutive off-curve points) based on previous and next segments.
654+
cdef SkPoint p1, p2, p3
655+
result = []
656+
it = itertools.chain([None], SegmentPenIterator(self), [None])
657+
for previous, current, next_ in triplewise(it):
658+
if (
659+
previous is not None
660+
and previous[0] == "moveTo"
661+
and current[0] == "qCurveTo"
662+
and next_ is not None
663+
and next_[0] == "closePath"
664+
and previous[1][0] == current[1][-1]
665+
):
666+
qpoints = current[1]
667+
last_off, move_pt, first_off = qpoints[-2], qpoints[-1], qpoints[0]
668+
p1 = SkPoint.Make(last_off[0], last_off[1])
669+
p2 = SkPoint.Make(move_pt[0], move_pt[1])
670+
p3 = SkPoint.Make(first_off[0], first_off[1])
671+
if is_middle_point(p1, p2, p3):
672+
# drop the moveTo and make the last on-curve None
673+
del result[-1]
674+
result.append((current[0], current[1][:-1] + (None,)))
675+
continue
676+
result.append(current)
677+
yield from result
621678

622679
cpdef Path transform(
623680
self,
@@ -919,8 +976,24 @@ cdef class PathPen:
919976
def qCurveTo(self, *points):
920977
num_offcurves = len(points) - 1
921978
if num_offcurves > 0:
979+
oncurveless_contour = points[-1] is None
980+
if oncurveless_contour:
981+
# Special case for TrueType closed contours without on-curve points.
982+
# FontTools pens supports this by allowing the last point of qCurveTo
983+
# to be None, which is translated as an implied on-curve point between
984+
# the last and the first off-curve points:
985+
# https://github.com/fonttools/fonttools/blob/02a0636/Lib/fontTools/pens/basePen.py#L332-L344
986+
# https://github.com/fonttools/skia-pathops/issues/45
987+
x, y = points[-2]
988+
nx, ny = points[0]
989+
implied_pt = (0.5 * (x + nx), 0.5 * (y + ny))
990+
self.moveTo(implied_pt)
991+
points = points[:-1] + (implied_pt,)
922992
for pt1, pt2 in _decompose_quadratic_segment(points):
923993
self._qCurveToOne(pt1, pt2)
994+
if oncurveless_contour:
995+
# oncurve-less contour is closed by definition
996+
self.closePath()
924997
elif num_offcurves == 0:
925998
self.lineTo(points[0])
926999
else:

tests/pathops_test.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,19 @@ def test_draw(self):
5353
pen.curveTo((3.5, 4), (5, 6), (7, 8))
5454
pen.qCurveTo((9, 10), (11, 12))
5555
pen.closePath()
56+
pen.qCurveTo((1.0, 1.0), (-1.0, 1.0), (-1.0, -1.0), (1.0, -1.0), None)
57+
pen.closePath() # always closed for oncurve-less contour
5658

5759
path2 = Path()
5860
path.draw(path2.getPen())
5961

6062
assert path == path2
63+
assert list(path.segments) == list(path2.segments)
64+
65+
assert list(path.segments)[-2:] == [
66+
('qCurveTo', ((1.0, 1.0), (-1.0, 1.0), (-1.0, -1.0), (1.0, -1.0), None)),
67+
('closePath', ())
68+
]
6169

6270
def test_allow_open_contour(self):
6371
path = Path()
@@ -107,6 +115,37 @@ def test_decompose_join_quadratic_segments(self):
107115
('qCurveTo', ((1.0, 1.0), (2.0, 2.0), (3.0, 3.0))),
108116
('closePath', ())]
109117

118+
def test_decompose_join_oncurveless_quadratic_segments(self):
119+
# when qCurveTo ends with None this is interpreted in FontTools pen protocol as
120+
# a special TrueType closed contour comprising a single quadratic B-spline in
121+
# which all the on-curve points are omitted and implied.
122+
# https://github.com/fonttools/skia-pathops/issues/45
123+
path = Path()
124+
pen = path.getPen()
125+
pen.qCurveTo((1.0, 1.0), (-1.0, 1.0), (-1.0, -1.0), (1.0, -1.0), None)
126+
# pen.closePath() # closed always implied in this case, so this call is no-op
127+
128+
items = list(path)
129+
assert len(items) == 6
130+
# the TrueType quadratic spline with N off-curves is stored internally
131+
# as N atomic quadratic Bezier segments with explicit on-curve points.
132+
# A move on-curve point is also added between the last and first off-curves.
133+
assert items == [
134+
(PathVerb.MOVE, ((1.0, 0.0),)),
135+
(PathVerb.QUAD, ((1.0, 1.0), (0.0, 1.0))),
136+
(PathVerb.QUAD, ((-1.0, 1.0), (-1.0, 0.0))),
137+
(PathVerb.QUAD, ((-1.0, -1.0), (0.0, -1.0))),
138+
(PathVerb.QUAD, ((1.0, -1.0), (1.0, 0.0))),
139+
(PathVerb.CLOSE, ()),
140+
]
141+
142+
# when drawn back onto a SegmentPen, implicit on-curves are omitted including
143+
# the last/move point
144+
assert list(path.segments) == [
145+
('qCurveTo', ((1.0, 1.0), (-1.0, 1.0), (-1.0, -1.0), (1.0, -1.0), None)),
146+
('closePath', ()),
147+
]
148+
110149
def test_qCurveTo_varargs(self):
111150
path = Path()
112151
pen = path.getPen()

0 commit comments

Comments
 (0)