• 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, PenError
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		if self.currentPath is not None:
78			raise PenError("Path already begun.")
79		self.currentPath = []
80
81	def _flushContour(self, segments):
82		"""Override this method.
83
84		It will be called for each non-empty sub path with a list
85		of segments: the 'segments' argument.
86
87		The segments list contains tuples of length 2:
88			(segmentType, points)
89
90		segmentType is one of "move", "line", "curve" or "qcurve".
91		"move" may only occur as the first segment, and it signifies
92		an OPEN path. A CLOSED path does NOT start with a "move", in
93		fact it will not contain a "move" at ALL.
94
95		The 'points' field in the 2-tuple is a list of point info
96		tuples. The list has 1 or more items, a point tuple has
97		four items:
98			(point, smooth, name, kwargs)
99		'point' is an (x, y) coordinate pair.
100
101		For a closed path, the initial moveTo point is defined as
102		the last point of the last segment.
103
104		The 'points' list of "move" and "line" segments always contains
105		exactly one point tuple.
106		"""
107		raise NotImplementedError
108
109	def endPath(self):
110		if self.currentPath is None:
111			raise PenError("Path not begun.")
112		points = self.currentPath
113		self.currentPath = None
114		if not points:
115			return
116		if len(points) == 1:
117			# Not much more we can do than output a single move segment.
118			pt, segmentType, smooth, name, kwargs = points[0]
119			segments = [("move", [(pt, smooth, name, kwargs)])]
120			self._flushContour(segments)
121			return
122		segments = []
123		if points[0][1] == "move":
124			# It's an open contour, insert a "move" segment for the first
125			# point and remove that first point from the point list.
126			pt, segmentType, smooth, name, kwargs = points[0]
127			segments.append(("move", [(pt, smooth, name, kwargs)]))
128			points.pop(0)
129		else:
130			# It's a closed contour. Locate the first on-curve point, and
131			# rotate the point list so that it _ends_ with an on-curve
132			# point.
133			firstOnCurve = None
134			for i in range(len(points)):
135				segmentType = points[i][1]
136				if segmentType is not None:
137					firstOnCurve = i
138					break
139			if firstOnCurve is None:
140				# Special case for quadratics: a contour with no on-curve
141				# points. Add a "None" point. (See also the Pen protocol's
142				# qCurveTo() method and fontTools.pens.basePen.py.)
143				points.append((None, "qcurve", None, None, None))
144			else:
145				points = points[firstOnCurve+1:] + points[:firstOnCurve+1]
146
147		currentSegment = []
148		for pt, segmentType, smooth, name, kwargs in points:
149			currentSegment.append((pt, smooth, name, kwargs))
150			if segmentType is None:
151				continue
152			segments.append((segmentType, currentSegment))
153			currentSegment = []
154
155		self._flushContour(segments)
156
157	def addPoint(self, pt, segmentType=None, smooth=False, name=None,
158				 identifier=None, **kwargs):
159		if self.currentPath is None:
160			raise PenError("Path not begun")
161		self.currentPath.append((pt, segmentType, smooth, name, kwargs))
162
163
164class PointToSegmentPen(BasePointToSegmentPen):
165	"""
166	Adapter class that converts the PointPen protocol to the
167	(Segment)Pen protocol.
168
169	NOTE: The segment pen does not support and will drop point names, identifiers
170	and kwargs.
171	"""
172
173	def __init__(self, segmentPen, outputImpliedClosingLine=False):
174		BasePointToSegmentPen.__init__(self)
175		self.pen = segmentPen
176		self.outputImpliedClosingLine = outputImpliedClosingLine
177
178	def _flushContour(self, segments):
179		if not segments:
180			raise PenError("Must have at least one segment.")
181		pen = self.pen
182		if segments[0][0] == "move":
183			# It's an open path.
184			closed = False
185			points = segments[0][1]
186			if len(points) != 1:
187				raise PenError(f"Illegal move segment point count: {len(points)}")
188			movePt, _, _ , _ = points[0]
189			del segments[0]
190		else:
191			# It's a closed path, do a moveTo to the last
192			# point of the last segment.
193			closed = True
194			segmentType, points = segments[-1]
195			movePt, _, _ , _ = points[-1]
196		if movePt is None:
197			# quad special case: a contour with no on-curve points contains
198			# one "qcurve" segment that ends with a point that's None. We
199			# must not output a moveTo() in that case.
200			pass
201		else:
202			pen.moveTo(movePt)
203		outputImpliedClosingLine = self.outputImpliedClosingLine
204		nSegments = len(segments)
205		lastPt = movePt
206		for i in range(nSegments):
207			segmentType, points = segments[i]
208			points = [pt for pt, _, _ , _ in points]
209			if segmentType == "line":
210				if len(points) != 1:
211					raise PenError(f"Illegal line segment point count: {len(points)}")
212				pt = points[0]
213				# For closed contours, a 'lineTo' is always implied from the last oncurve
214				# point to the starting point, thus we can omit it when the last and
215				# starting point don't overlap.
216				# However, when the last oncurve point is a "line" segment and has same
217				# coordinates as the starting point of a closed contour, we need to output
218				# the closing 'lineTo' explicitly (regardless of the value of the
219				# 'outputImpliedClosingLine' option) in order to disambiguate this case from
220				# the implied closing 'lineTo', otherwise the duplicate point would be lost.
221				# See https://github.com/googlefonts/fontmake/issues/572.
222				if (
223					i + 1 != nSegments
224					or outputImpliedClosingLine
225					or not closed
226					or pt == lastPt
227				):
228					pen.lineTo(pt)
229					lastPt = pt
230			elif segmentType == "curve":
231				pen.curveTo(*points)
232				lastPt = points[-1]
233			elif segmentType == "qcurve":
234				pen.qCurveTo(*points)
235				lastPt = points[-1]
236			else:
237				raise PenError(f"Illegal segmentType: {segmentType}")
238		if closed:
239			pen.closePath()
240		else:
241			pen.endPath()
242
243	def addComponent(self, glyphName, transform, identifier=None, **kwargs):
244		del identifier  # unused
245		del kwargs  # unused
246		self.pen.addComponent(glyphName, transform)
247
248
249class SegmentToPointPen(AbstractPen):
250	"""
251	Adapter class that converts the (Segment)Pen protocol to the
252	PointPen protocol.
253	"""
254
255	def __init__(self, pointPen, guessSmooth=True):
256		if guessSmooth:
257			self.pen = GuessSmoothPointPen(pointPen)
258		else:
259			self.pen = pointPen
260		self.contour = None
261
262	def _flushContour(self):
263		pen = self.pen
264		pen.beginPath()
265		for pt, segmentType in self.contour:
266			pen.addPoint(pt, segmentType=segmentType)
267		pen.endPath()
268
269	def moveTo(self, pt):
270		self.contour = []
271		self.contour.append((pt, "move"))
272
273	def lineTo(self, pt):
274		if self.contour is None:
275			raise PenError("Contour missing required initial moveTo")
276		self.contour.append((pt, "line"))
277
278	def curveTo(self, *pts):
279		if not pts:
280			raise TypeError("Must pass in at least one point")
281		if self.contour is None:
282			raise PenError("Contour missing required initial moveTo")
283		for pt in pts[:-1]:
284			self.contour.append((pt, None))
285		self.contour.append((pts[-1], "curve"))
286
287	def qCurveTo(self, *pts):
288		if not pts:
289			raise TypeError("Must pass in at least one point")
290		if pts[-1] is None:
291			self.contour = []
292		else:
293			if self.contour is None:
294				raise PenError("Contour missing required initial moveTo")
295		for pt in pts[:-1]:
296			self.contour.append((pt, None))
297		if pts[-1] is not None:
298			self.contour.append((pts[-1], "qcurve"))
299
300	def closePath(self):
301		if self.contour is None:
302			raise PenError("Contour missing required initial moveTo")
303		if len(self.contour) > 1 and self.contour[0][0] == self.contour[-1][0]:
304			self.contour[0] = self.contour[-1]
305			del self.contour[-1]
306		else:
307			# There's an implied line at the end, replace "move" with "line"
308			# for the first point
309			pt, tp = self.contour[0]
310			if tp == "move":
311				self.contour[0] = pt, "line"
312		self._flushContour()
313		self.contour = None
314
315	def endPath(self):
316		if self.contour is None:
317			raise PenError("Contour missing required initial moveTo")
318		self._flushContour()
319		self.contour = None
320
321	def addComponent(self, glyphName, transform):
322		if self.contour is not None:
323			raise PenError("Components must be added before or after contours")
324		self.pen.addComponent(glyphName, transform)
325
326
327class GuessSmoothPointPen(AbstractPointPen):
328	"""
329	Filtering PointPen that tries to determine whether an on-curve point
330	should be "smooth", ie. that it's a "tangent" point or a "curve" point.
331	"""
332
333	def __init__(self, outPen, error=0.05):
334		self._outPen = outPen
335		self._error = error
336		self._points = None
337
338	def _flushContour(self):
339		if self._points is None:
340			raise PenError("Path not begun")
341		points = self._points
342		nPoints = len(points)
343		if not nPoints:
344			return
345		if points[0][1] == "move":
346			# Open path.
347			indices = range(1, nPoints - 1)
348		elif nPoints > 1:
349			# Closed path. To avoid having to mod the contour index, we
350			# simply abuse Python's negative index feature, and start at -1
351			indices = range(-1, nPoints - 1)
352		else:
353			# closed path containing 1 point (!), ignore.
354			indices = []
355		for i in indices:
356			pt, segmentType, _, name, kwargs = points[i]
357			if segmentType is None:
358				continue
359			prev = i - 1
360			next = i + 1
361			if points[prev][1] is not None and points[next][1] is not None:
362				continue
363			# At least one of our neighbors is an off-curve point
364			pt = points[i][0]
365			prevPt = points[prev][0]
366			nextPt = points[next][0]
367			if pt != prevPt and pt != nextPt:
368				dx1, dy1 = pt[0] - prevPt[0], pt[1] - prevPt[1]
369				dx2, dy2 = nextPt[0] - pt[0], nextPt[1] - pt[1]
370				a1 = math.atan2(dy1, dx1)
371				a2 = math.atan2(dy2, dx2)
372				if abs(a1 - a2) < self._error:
373					points[i] = pt, segmentType, True, name, kwargs
374
375		for pt, segmentType, smooth, name, kwargs in points:
376			self._outPen.addPoint(pt, segmentType, smooth, name, **kwargs)
377
378	def beginPath(self, identifier=None, **kwargs):
379		if self._points is not None:
380			raise PenError("Path already begun")
381		self._points = []
382		if identifier is not None:
383			kwargs["identifier"] = identifier
384		self._outPen.beginPath(**kwargs)
385
386	def endPath(self):
387		self._flushContour()
388		self._outPen.endPath()
389		self._points = None
390
391	def addPoint(self, pt, segmentType=None, smooth=False, name=None,
392				 identifier=None, **kwargs):
393		if self._points is None:
394			raise PenError("Path not begun")
395		if identifier is not None:
396			kwargs["identifier"] = identifier
397		self._points.append((pt, segmentType, False, name, kwargs))
398
399	def addComponent(self, glyphName, transformation, identifier=None, **kwargs):
400		if self._points is not None:
401			raise PenError("Components must be added before or after contours")
402		if identifier is not None:
403			kwargs["identifier"] = identifier
404		self._outPen.addComponent(glyphName, transformation, **kwargs)
405
406
407class ReverseContourPointPen(AbstractPointPen):
408	"""
409	This is a PointPen that passes outline data to another PointPen, but
410	reversing the winding direction of all contours. Components are simply
411	passed through unchanged.
412
413	Closed contours are reversed in such a way that the first point remains
414	the first point.
415	"""
416
417	def __init__(self, outputPointPen):
418		self.pen = outputPointPen
419		# a place to store the points for the current sub path
420		self.currentContour = None
421
422	def _flushContour(self):
423		pen = self.pen
424		contour = self.currentContour
425		if not contour:
426			pen.beginPath(identifier=self.currentContourIdentifier)
427			pen.endPath()
428			return
429
430		closed = contour[0][1] != "move"
431		if not closed:
432			lastSegmentType = "move"
433		else:
434			# Remove the first point and insert it at the end. When
435			# the list of points gets reversed, this point will then
436			# again be at the start. In other words, the following
437			# will hold:
438			#   for N in range(len(originalContour)):
439			#       originalContour[N] == reversedContour[-N]
440			contour.append(contour.pop(0))
441			# Find the first on-curve point.
442			firstOnCurve = None
443			for i in range(len(contour)):
444				if contour[i][1] is not None:
445					firstOnCurve = i
446					break
447			if firstOnCurve is None:
448				# There are no on-curve points, be basically have to
449				# do nothing but contour.reverse().
450				lastSegmentType = None
451			else:
452				lastSegmentType = contour[firstOnCurve][1]
453
454		contour.reverse()
455		if not closed:
456			# Open paths must start with a move, so we simply dump
457			# all off-curve points leading up to the first on-curve.
458			while contour[0][1] is None:
459				contour.pop(0)
460		pen.beginPath(identifier=self.currentContourIdentifier)
461		for pt, nextSegmentType, smooth, name, kwargs in contour:
462			if nextSegmentType is not None:
463				segmentType = lastSegmentType
464				lastSegmentType = nextSegmentType
465			else:
466				segmentType = None
467			pen.addPoint(pt, segmentType=segmentType, smooth=smooth, name=name, **kwargs)
468		pen.endPath()
469
470	def beginPath(self, identifier=None, **kwargs):
471		if self.currentContour is not None:
472			raise PenError("Path already begun")
473		self.currentContour = []
474		self.currentContourIdentifier = identifier
475		self.onCurve = []
476
477	def endPath(self):
478		if self.currentContour is None:
479			raise PenError("Path not begun")
480		self._flushContour()
481		self.currentContour = None
482
483	def addPoint(self, pt, segmentType=None, smooth=False, name=None, identifier=None, **kwargs):
484		if self.currentContour is None:
485			raise PenError("Path not begun")
486		if identifier is not None:
487			kwargs["identifier"] = identifier
488		self.currentContour.append((pt, segmentType, smooth, name, kwargs))
489
490	def addComponent(self, glyphName, transform, identifier=None, **kwargs):
491		if self.currentContour is not None:
492			raise PenError("Components must be added before or after contours")
493		self.pen.addComponent(glyphName, transform, identifier=identifier, **kwargs)
494