• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1"""
2=========
3PointPens
4=========
5
6Where **SegmentPens** have an intuitive approach to drawing
7(if you're familiar with postscript anyway), the **PointPen**
8is geared towards accessing all the data in the contours of
9the glyph. A PointPen has a very simple interface, it just
10steps through all the points in a call from glyph.drawPoints().
11This allows the caller to provide more data for each point.
12For instance, whether or not a point is smooth, and its name.
13"""
14
15import math
16from typing import Any, Optional, Tuple
17
18from fontTools.pens.basePen import AbstractPen
19
20__all__ = [
21	"AbstractPointPen",
22	"BasePointToSegmentPen",
23	"PointToSegmentPen",
24	"SegmentToPointPen",
25	"GuessSmoothPointPen",
26	"ReverseContourPointPen",
27]
28
29
30class AbstractPointPen:
31	"""Baseclass for all PointPens."""
32
33	def beginPath(self, identifier: Optional[str] = None, **kwargs: Any) -> None:
34		"""Start a new sub path."""
35		raise NotImplementedError
36
37	def endPath(self) -> None:
38		"""End the current sub path."""
39		raise NotImplementedError
40
41	def addPoint(
42		self,
43		pt: Tuple[float, float],
44		segmentType: Optional[str] = None,
45		smooth: bool = False,
46		name: Optional[str] = None,
47		identifier: Optional[str] = None,
48		**kwargs: Any
49	) -> None:
50		"""Add a point to the current sub path."""
51		raise NotImplementedError
52
53	def addComponent(
54		self,
55		baseGlyphName: str,
56		transformation: Tuple[float, float, float, float, float, float],
57		identifier: Optional[str] = None,
58		**kwargs: Any
59	) -> None:
60		"""Add a sub glyph."""
61		raise NotImplementedError
62
63
64class BasePointToSegmentPen(AbstractPointPen):
65	"""
66	Base class for retrieving the outline in a segment-oriented
67	way. The PointPen protocol is simple yet also a little tricky,
68	so when you need an outline presented as segments but you have
69	as points, do use this base implementation as it properly takes
70	care of all the edge cases.
71	"""
72
73	def __init__(self):
74		self.currentPath = None
75
76	def beginPath(self, identifier=None, **kwargs):
77		assert self.currentPath is None
78		self.currentPath = []
79
80	def _flushContour(self, segments):
81		"""Override this method.
82
83		It will be called for each non-empty sub path with a list
84		of segments: the 'segments' argument.
85
86		The segments list contains tuples of length 2:
87			(segmentType, points)
88
89		segmentType is one of "move", "line", "curve" or "qcurve".
90		"move" may only occur as the first segment, and it signifies
91		an OPEN path. A CLOSED path does NOT start with a "move", in
92		fact it will not contain a "move" at ALL.
93
94		The 'points' field in the 2-tuple is a list of point info
95		tuples. The list has 1 or more items, a point tuple has
96		four items:
97			(point, smooth, name, kwargs)
98		'point' is an (x, y) coordinate pair.
99
100		For a closed path, the initial moveTo point is defined as
101		the last point of the last segment.
102
103		The 'points' list of "move" and "line" segments always contains
104		exactly one point tuple.
105		"""
106		raise NotImplementedError
107
108	def endPath(self):
109		assert self.currentPath is not None
110		points = self.currentPath
111		self.currentPath = None
112		if not points:
113			return
114		if len(points) == 1:
115			# Not much more we can do than output a single move segment.
116			pt, segmentType, smooth, name, kwargs = points[0]
117			segments = [("move", [(pt, smooth, name, kwargs)])]
118			self._flushContour(segments)
119			return
120		segments = []
121		if points[0][1] == "move":
122			# It's an open contour, insert a "move" segment for the first
123			# point and remove that first point from the point list.
124			pt, segmentType, smooth, name, kwargs = points[0]
125			segments.append(("move", [(pt, smooth, name, kwargs)]))
126			points.pop(0)
127		else:
128			# It's a closed contour. Locate the first on-curve point, and
129			# rotate the point list so that it _ends_ with an on-curve
130			# point.
131			firstOnCurve = None
132			for i in range(len(points)):
133				segmentType = points[i][1]
134				if segmentType is not None:
135					firstOnCurve = i
136					break
137			if firstOnCurve is None:
138				# Special case for quadratics: a contour with no on-curve
139				# points. Add a "None" point. (See also the Pen protocol's
140				# qCurveTo() method and fontTools.pens.basePen.py.)
141				points.append((None, "qcurve", None, None, None))
142			else:
143				points = points[firstOnCurve+1:] + points[:firstOnCurve+1]
144
145		currentSegment = []
146		for pt, segmentType, smooth, name, kwargs in points:
147			currentSegment.append((pt, smooth, name, kwargs))
148			if segmentType is None:
149				continue
150			segments.append((segmentType, currentSegment))
151			currentSegment = []
152
153		self._flushContour(segments)
154
155	def addPoint(self, pt, segmentType=None, smooth=False, name=None,
156				 identifier=None, **kwargs):
157		self.currentPath.append((pt, segmentType, smooth, name, kwargs))
158
159
160class PointToSegmentPen(BasePointToSegmentPen):
161	"""
162	Adapter class that converts the PointPen protocol to the
163	(Segment)Pen protocol.
164	"""
165
166	def __init__(self, segmentPen, outputImpliedClosingLine=False):
167		BasePointToSegmentPen.__init__(self)
168		self.pen = segmentPen
169		self.outputImpliedClosingLine = outputImpliedClosingLine
170
171	def _flushContour(self, segments):
172		assert len(segments) >= 1
173		pen = self.pen
174		if segments[0][0] == "move":
175			# It's an open path.
176			closed = False
177			points = segments[0][1]
178			assert len(points) == 1, "illegal move segment point count: %d" % len(points)
179			movePt, smooth, name, kwargs = points[0]
180			del segments[0]
181		else:
182			# It's a closed path, do a moveTo to the last
183			# point of the last segment.
184			closed = True
185			segmentType, points = segments[-1]
186			movePt, smooth, name, kwargs = points[-1]
187		if movePt is None:
188			# quad special case: a contour with no on-curve points contains
189			# one "qcurve" segment that ends with a point that's None. We
190			# must not output a moveTo() in that case.
191			pass
192		else:
193			pen.moveTo(movePt)
194		outputImpliedClosingLine = self.outputImpliedClosingLine
195		nSegments = len(segments)
196		lastPt = movePt
197		for i in range(nSegments):
198			segmentType, points = segments[i]
199			points = [pt for pt, smooth, name, kwargs in points]
200			if segmentType == "line":
201				assert len(points) == 1, "illegal line segment point count: %d" % len(points)
202				pt = points[0]
203				# For closed contours, a 'lineTo' is always implied from the last oncurve
204				# point to the starting point, thus we can omit it when the last and
205				# starting point don't overlap.
206				# However, when the last oncurve point is a "line" segment and has same
207				# coordinates as the starting point of a closed contour, we need to output
208				# the closing 'lineTo' explicitly (regardless of the value of the
209				# 'outputImpliedClosingLine' option) in order to disambiguate this case from
210				# the implied closing 'lineTo', otherwise the duplicate point would be lost.
211				# See https://github.com/googlefonts/fontmake/issues/572.
212				if (
213					i + 1 != nSegments
214					or outputImpliedClosingLine
215					or not closed
216					or pt == lastPt
217				):
218					pen.lineTo(pt)
219					lastPt = pt
220			elif segmentType == "curve":
221				pen.curveTo(*points)
222				lastPt = points[-1]
223			elif segmentType == "qcurve":
224				pen.qCurveTo(*points)
225				lastPt = points[-1]
226			else:
227				assert 0, "illegal segmentType: %s" % segmentType
228		if closed:
229			pen.closePath()
230		else:
231			pen.endPath()
232
233	def addComponent(self, glyphName, transform, identifier=None, **kwargs):
234		del identifier  # unused
235		self.pen.addComponent(glyphName, transform)
236
237
238class SegmentToPointPen(AbstractPen):
239	"""
240	Adapter class that converts the (Segment)Pen protocol to the
241	PointPen protocol.
242	"""
243
244	def __init__(self, pointPen, guessSmooth=True):
245		if guessSmooth:
246			self.pen = GuessSmoothPointPen(pointPen)
247		else:
248			self.pen = pointPen
249		self.contour = None
250
251	def _flushContour(self):
252		pen = self.pen
253		pen.beginPath()
254		for pt, segmentType in self.contour:
255			pen.addPoint(pt, segmentType=segmentType)
256		pen.endPath()
257
258	def moveTo(self, pt):
259		self.contour = []
260		self.contour.append((pt, "move"))
261
262	def lineTo(self, pt):
263		assert self.contour is not None, "contour missing required initial moveTo"
264		self.contour.append((pt, "line"))
265
266	def curveTo(self, *pts):
267		assert self.contour is not None, "contour missing required initial moveTo"
268		for pt in pts[:-1]:
269			self.contour.append((pt, None))
270		self.contour.append((pts[-1], "curve"))
271
272	def qCurveTo(self, *pts):
273		if pts[-1] is None:
274			self.contour = []
275		else:
276			assert self.contour is not None, "contour missing required initial moveTo"
277		for pt in pts[:-1]:
278			self.contour.append((pt, None))
279		if pts[-1] is not None:
280			self.contour.append((pts[-1], "qcurve"))
281
282	def closePath(self):
283		assert self.contour is not None, "contour missing required initial moveTo"
284		if len(self.contour) > 1 and self.contour[0][0] == self.contour[-1][0]:
285			self.contour[0] = self.contour[-1]
286			del self.contour[-1]
287		else:
288			# There's an implied line at the end, replace "move" with "line"
289			# for the first point
290			pt, tp = self.contour[0]
291			if tp == "move":
292				self.contour[0] = pt, "line"
293		self._flushContour()
294		self.contour = None
295
296	def endPath(self):
297		assert self.contour is not None, "contour missing required initial moveTo"
298		self._flushContour()
299		self.contour = None
300
301	def addComponent(self, glyphName, transform):
302		assert self.contour is None
303		self.pen.addComponent(glyphName, transform)
304
305
306class GuessSmoothPointPen(AbstractPointPen):
307	"""
308	Filtering PointPen that tries to determine whether an on-curve point
309	should be "smooth", ie. that it's a "tangent" point or a "curve" point.
310	"""
311
312	def __init__(self, outPen):
313		self._outPen = outPen
314		self._points = None
315
316	def _flushContour(self):
317		points = self._points
318		nPoints = len(points)
319		if not nPoints:
320			return
321		if points[0][1] == "move":
322			# Open path.
323			indices = range(1, nPoints - 1)
324		elif nPoints > 1:
325			# Closed path. To avoid having to mod the contour index, we
326			# simply abuse Python's negative index feature, and start at -1
327			indices = range(-1, nPoints - 1)
328		else:
329			# closed path containing 1 point (!), ignore.
330			indices = []
331		for i in indices:
332			pt, segmentType, dummy, name, kwargs = points[i]
333			if segmentType is None:
334				continue
335			prev = i - 1
336			next = i + 1
337			if points[prev][1] is not None and points[next][1] is not None:
338				continue
339			# At least one of our neighbors is an off-curve point
340			pt = points[i][0]
341			prevPt = points[prev][0]
342			nextPt = points[next][0]
343			if pt != prevPt and pt != nextPt:
344				dx1, dy1 = pt[0] - prevPt[0], pt[1] - prevPt[1]
345				dx2, dy2 = nextPt[0] - pt[0], nextPt[1] - pt[1]
346				a1 = math.atan2(dx1, dy1)
347				a2 = math.atan2(dx2, dy2)
348				if abs(a1 - a2) < 0.05:
349					points[i] = pt, segmentType, True, name, kwargs
350
351		for pt, segmentType, smooth, name, kwargs in points:
352			self._outPen.addPoint(pt, segmentType, smooth, name, **kwargs)
353
354	def beginPath(self, identifier=None, **kwargs):
355		assert self._points is None
356		self._points = []
357		if identifier is not None:
358			kwargs["identifier"] = identifier
359		self._outPen.beginPath(**kwargs)
360
361	def endPath(self):
362		self._flushContour()
363		self._outPen.endPath()
364		self._points = None
365
366	def addPoint(self, pt, segmentType=None, smooth=False, name=None,
367				 identifier=None, **kwargs):
368		if identifier is not None:
369			kwargs["identifier"] = identifier
370		self._points.append((pt, segmentType, False, name, kwargs))
371
372	def addComponent(self, glyphName, transformation, identifier=None, **kwargs):
373		assert self._points is None
374		if identifier is not None:
375			kwargs["identifier"] = identifier
376		self._outPen.addComponent(glyphName, transformation, **kwargs)
377
378
379class ReverseContourPointPen(AbstractPointPen):
380	"""
381	This is a PointPen that passes outline data to another PointPen, but
382	reversing the winding direction of all contours. Components are simply
383	passed through unchanged.
384
385	Closed contours are reversed in such a way that the first point remains
386	the first point.
387	"""
388
389	def __init__(self, outputPointPen):
390		self.pen = outputPointPen
391		# a place to store the points for the current sub path
392		self.currentContour = None
393
394	def _flushContour(self):
395		pen = self.pen
396		contour = self.currentContour
397		if not contour:
398			pen.beginPath(identifier=self.currentContourIdentifier)
399			pen.endPath()
400			return
401
402		closed = contour[0][1] != "move"
403		if not closed:
404			lastSegmentType = "move"
405		else:
406			# Remove the first point and insert it at the end. When
407			# the list of points gets reversed, this point will then
408			# again be at the start. In other words, the following
409			# will hold:
410			#   for N in range(len(originalContour)):
411			#       originalContour[N] == reversedContour[-N]
412			contour.append(contour.pop(0))
413			# Find the first on-curve point.
414			firstOnCurve = None
415			for i in range(len(contour)):
416				if contour[i][1] is not None:
417					firstOnCurve = i
418					break
419			if firstOnCurve is None:
420				# There are no on-curve points, be basically have to
421				# do nothing but contour.reverse().
422				lastSegmentType = None
423			else:
424				lastSegmentType = contour[firstOnCurve][1]
425
426		contour.reverse()
427		if not closed:
428			# Open paths must start with a move, so we simply dump
429			# all off-curve points leading up to the first on-curve.
430			while contour[0][1] is None:
431				contour.pop(0)
432		pen.beginPath(identifier=self.currentContourIdentifier)
433		for pt, nextSegmentType, smooth, name, kwargs in contour:
434			if nextSegmentType is not None:
435				segmentType = lastSegmentType
436				lastSegmentType = nextSegmentType
437			else:
438				segmentType = None
439			pen.addPoint(pt, segmentType=segmentType, smooth=smooth, name=name, **kwargs)
440		pen.endPath()
441
442	def beginPath(self, identifier=None, **kwargs):
443		assert self.currentContour is None
444		self.currentContour = []
445		self.currentContourIdentifier = identifier
446		self.onCurve = []
447
448	def endPath(self):
449		assert self.currentContour is not None
450		self._flushContour()
451		self.currentContour = None
452
453	def addPoint(self, pt, segmentType=None, smooth=False, name=None, **kwargs):
454		self.currentContour.append((pt, segmentType, smooth, name, kwargs))
455
456	def addComponent(self, glyphName, transform, identifier=None, **kwargs):
457		assert self.currentContour is None
458		self.pen.addComponent(glyphName, transform, identifier=identifier, **kwargs)
459