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, Dict
17
18from fontTools.misc.loggingTools import LogMixin
19from fontTools.pens.basePen import AbstractPen, MissingComponentError, PenError
20from fontTools.misc.transform import DecomposedTransform, Identity
21
22__all__ = [
23 "AbstractPointPen",
24 "BasePointToSegmentPen",
25 "PointToSegmentPen",
26 "SegmentToPointPen",
27 "GuessSmoothPointPen",
28 "ReverseContourPointPen",
29]
30
31
32class AbstractPointPen:
33 """Baseclass for all PointPens."""
34
35 def beginPath(self, identifier: Optional[str] = None, **kwargs: Any) -> None:
36 """Start a new sub path."""
37 raise NotImplementedError
38
39 def endPath(self) -> None:
40 """End the current sub path."""
41 raise NotImplementedError
42
43 def addPoint(
44 self,
45 pt: Tuple[float, float],
46 segmentType: Optional[str] = None,
47 smooth: bool = False,
48 name: Optional[str] = None,
49 identifier: Optional[str] = None,
50 **kwargs: Any,
51 ) -> None:
52 """Add a point to the current sub path."""
53 raise NotImplementedError
54
55 def addComponent(
56 self,
57 baseGlyphName: str,
58 transformation: Tuple[float, float, float, float, float, float],
59 identifier: Optional[str] = None,
60 **kwargs: Any,
61 ) -> None:
62 """Add a sub glyph."""
63 raise NotImplementedError
64
65 def addVarComponent(
66 self,
67 glyphName: str,
68 transformation: DecomposedTransform,
69 location: Dict[str, float],
70 identifier: Optional[str] = None,
71 **kwargs: Any,
72 ) -> None:
73 """Add a VarComponent sub glyph. The 'transformation' argument
74 must be a DecomposedTransform from the fontTools.misc.transform module,
75 and the 'location' argument must be a dictionary mapping axis tags
76 to their locations.
77 """
78 # ttGlyphSet decomposes for us
79 raise AttributeError
80
81
82class BasePointToSegmentPen(AbstractPointPen):
83 """
84 Base class for retrieving the outline in a segment-oriented
85 way. The PointPen protocol is simple yet also a little tricky,
86 so when you need an outline presented as segments but you have
87 as points, do use this base implementation as it properly takes
88 care of all the edge cases.
89 """
90
91 def __init__(self):
92 self.currentPath = None
93
94 def beginPath(self, identifier=None, **kwargs):
95 if self.currentPath is not None:
96 raise PenError("Path already begun.")
97 self.currentPath = []
98
99 def _flushContour(self, segments):
100 """Override this method.
101
102 It will be called for each non-empty sub path with a list
103 of segments: the 'segments' argument.
104
105 The segments list contains tuples of length 2:
106 (segmentType, points)
107
108 segmentType is one of "move", "line", "curve" or "qcurve".
109 "move" may only occur as the first segment, and it signifies
110 an OPEN path. A CLOSED path does NOT start with a "move", in
111 fact it will not contain a "move" at ALL.
112
113 The 'points' field in the 2-tuple is a list of point info
114 tuples. The list has 1 or more items, a point tuple has
115 four items:
116 (point, smooth, name, kwargs)
117 'point' is an (x, y) coordinate pair.
118
119 For a closed path, the initial moveTo point is defined as
120 the last point of the last segment.
121
122 The 'points' list of "move" and "line" segments always contains
123 exactly one point tuple.
124 """
125 raise NotImplementedError
126
127 def endPath(self):
128 if self.currentPath is None:
129 raise PenError("Path not begun.")
130 points = self.currentPath
131 self.currentPath = None
132 if not points:
133 return
134 if len(points) == 1:
135 # Not much more we can do than output a single move segment.
136 pt, segmentType, smooth, name, kwargs = points[0]
137 segments = [("move", [(pt, smooth, name, kwargs)])]
138 self._flushContour(segments)
139 return
140 segments = []
141 if points[0][1] == "move":
142 # It's an open contour, insert a "move" segment for the first
143 # point and remove that first point from the point list.
144 pt, segmentType, smooth, name, kwargs = points[0]
145 segments.append(("move", [(pt, smooth, name, kwargs)]))
146 points.pop(0)
147 else:
148 # It's a closed contour. Locate the first on-curve point, and
149 # rotate the point list so that it _ends_ with an on-curve
150 # point.
151 firstOnCurve = None
152 for i in range(len(points)):
153 segmentType = points[i][1]
154 if segmentType is not None:
155 firstOnCurve = i
156 break
157 if firstOnCurve is None:
158 # Special case for quadratics: a contour with no on-curve
159 # points. Add a "None" point. (See also the Pen protocol's
160 # qCurveTo() method and fontTools.pens.basePen.py.)
161 points.append((None, "qcurve", None, None, None))
162 else:
163 points = points[firstOnCurve + 1 :] + points[: firstOnCurve + 1]
164
165 currentSegment = []
166 for pt, segmentType, smooth, name, kwargs in points:
167 currentSegment.append((pt, smooth, name, kwargs))
168 if segmentType is None:
169 continue
170 segments.append((segmentType, currentSegment))
171 currentSegment = []
172
173 self._flushContour(segments)
174
175 def addPoint(
176 self, pt, segmentType=None, smooth=False, name=None, identifier=None, **kwargs
177 ):
178 if self.currentPath is None:
179 raise PenError("Path not begun")
180 self.currentPath.append((pt, segmentType, smooth, name, kwargs))
181
182
183class PointToSegmentPen(BasePointToSegmentPen):
184 """
185 Adapter class that converts the PointPen protocol to the
186 (Segment)Pen protocol.
187
188 NOTE: The segment pen does not support and will drop point names, identifiers
189 and kwargs.
190 """
191
192 def __init__(self, segmentPen, outputImpliedClosingLine=False):
193 BasePointToSegmentPen.__init__(self)
194 self.pen = segmentPen
195 self.outputImpliedClosingLine = outputImpliedClosingLine
196
197 def _flushContour(self, segments):
198 if not segments:
199 raise PenError("Must have at least one segment.")
200 pen = self.pen
201 if segments[0][0] == "move":
202 # It's an open path.
203 closed = False
204 points = segments[0][1]
205 if len(points) != 1:
206 raise PenError(f"Illegal move segment point count: {len(points)}")
207 movePt, _, _, _ = points[0]
208 del segments[0]
209 else:
210 # It's a closed path, do a moveTo to the last
211 # point of the last segment.
212 closed = True
213 segmentType, points = segments[-1]
214 movePt, _, _, _ = points[-1]
215 if movePt is None:
216 # quad special case: a contour with no on-curve points contains
217 # one "qcurve" segment that ends with a point that's None. We
218 # must not output a moveTo() in that case.
219 pass
220 else:
221 pen.moveTo(movePt)
222 outputImpliedClosingLine = self.outputImpliedClosingLine
223 nSegments = len(segments)
224 lastPt = movePt
225 for i in range(nSegments):
226 segmentType, points = segments[i]
227 points = [pt for pt, _, _, _ in points]
228 if segmentType == "line":
229 if len(points) != 1:
230 raise PenError(f"Illegal line segment point count: {len(points)}")
231 pt = points[0]
232 # For closed contours, a 'lineTo' is always implied from the last oncurve
233 # point to the starting point, thus we can omit it when the last and
234 # starting point don't overlap.
235 # However, when the last oncurve point is a "line" segment and has same
236 # coordinates as the starting point of a closed contour, we need to output
237 # the closing 'lineTo' explicitly (regardless of the value of the
238 # 'outputImpliedClosingLine' option) in order to disambiguate this case from
239 # the implied closing 'lineTo', otherwise the duplicate point would be lost.
240 # See https://github.com/googlefonts/fontmake/issues/572.
241 if (
242 i + 1 != nSegments
243 or outputImpliedClosingLine
244 or not closed
245 or pt == lastPt
246 ):
247 pen.lineTo(pt)
248 lastPt = pt
249 elif segmentType == "curve":
250 pen.curveTo(*points)
251 lastPt = points[-1]
252 elif segmentType == "qcurve":
253 pen.qCurveTo(*points)
254 lastPt = points[-1]
255 else:
256 raise PenError(f"Illegal segmentType: {segmentType}")
257 if closed:
258 pen.closePath()
259 else:
260 pen.endPath()
261
262 def addComponent(self, glyphName, transform, identifier=None, **kwargs):
263 del identifier # unused
264 del kwargs # unused
265 self.pen.addComponent(glyphName, transform)
266
267
268class SegmentToPointPen(AbstractPen):
269 """
270 Adapter class that converts the (Segment)Pen protocol to the
271 PointPen protocol.
272 """
273
274 def __init__(self, pointPen, guessSmooth=True):
275 if guessSmooth:
276 self.pen = GuessSmoothPointPen(pointPen)
277 else:
278 self.pen = pointPen
279 self.contour = None
280
281 def _flushContour(self):
282 pen = self.pen
283 pen.beginPath()
284 for pt, segmentType in self.contour:
285 pen.addPoint(pt, segmentType=segmentType)
286 pen.endPath()
287
288 def moveTo(self, pt):
289 self.contour = []
290 self.contour.append((pt, "move"))
291
292 def lineTo(self, pt):
293 if self.contour is None:
294 raise PenError("Contour missing required initial moveTo")
295 self.contour.append((pt, "line"))
296
297 def curveTo(self, *pts):
298 if not pts:
299 raise TypeError("Must pass in at least one point")
300 if self.contour is None:
301 raise PenError("Contour missing required initial moveTo")
302 for pt in pts[:-1]:
303 self.contour.append((pt, None))
304 self.contour.append((pts[-1], "curve"))
305
306 def qCurveTo(self, *pts):
307 if not pts:
308 raise TypeError("Must pass in at least one point")
309 if pts[-1] is None:
310 self.contour = []
311 else:
312 if self.contour is None:
313 raise PenError("Contour missing required initial moveTo")
314 for pt in pts[:-1]:
315 self.contour.append((pt, None))
316 if pts[-1] is not None:
317 self.contour.append((pts[-1], "qcurve"))
318
319 def closePath(self):
320 if self.contour is None:
321 raise PenError("Contour missing required initial moveTo")
322 if len(self.contour) > 1 and self.contour[0][0] == self.contour[-1][0]:
323 self.contour[0] = self.contour[-1]
324 del self.contour[-1]
325 else:
326 # There's an implied line at the end, replace "move" with "line"
327 # for the first point
328 pt, tp = self.contour[0]
329 if tp == "move":
330 self.contour[0] = pt, "line"
331 self._flushContour()
332 self.contour = None
333
334 def endPath(self):
335 if self.contour is None:
336 raise PenError("Contour missing required initial moveTo")
337 self._flushContour()
338 self.contour = None
339
340 def addComponent(self, glyphName, transform):
341 if self.contour is not None:
342 raise PenError("Components must be added before or after contours")
343 self.pen.addComponent(glyphName, transform)
344
345
346class GuessSmoothPointPen(AbstractPointPen):
347 """
348 Filtering PointPen that tries to determine whether an on-curve point
349 should be "smooth", ie. that it's a "tangent" point or a "curve" point.
350 """
351
352 def __init__(self, outPen, error=0.05):
353 self._outPen = outPen
354 self._error = error
355 self._points = None
356
357 def _flushContour(self):
358 if self._points is None:
359 raise PenError("Path not begun")
360 points = self._points
361 nPoints = len(points)
362 if not nPoints:
363 return
364 if points[0][1] == "move":
365 # Open path.
366 indices = range(1, nPoints - 1)
367 elif nPoints > 1:
368 # Closed path. To avoid having to mod the contour index, we
369 # simply abuse Python's negative index feature, and start at -1
370 indices = range(-1, nPoints - 1)
371 else:
372 # closed path containing 1 point (!), ignore.
373 indices = []
374 for i in indices:
375 pt, segmentType, _, name, kwargs = points[i]
376 if segmentType is None:
377 continue
378 prev = i - 1
379 next = i + 1
380 if points[prev][1] is not None and points[next][1] is not None:
381 continue
382 # At least one of our neighbors is an off-curve point
383 pt = points[i][0]
384 prevPt = points[prev][0]
385 nextPt = points[next][0]
386 if pt != prevPt and pt != nextPt:
387 dx1, dy1 = pt[0] - prevPt[0], pt[1] - prevPt[1]
388 dx2, dy2 = nextPt[0] - pt[0], nextPt[1] - pt[1]
389 a1 = math.atan2(dy1, dx1)
390 a2 = math.atan2(dy2, dx2)
391 if abs(a1 - a2) < self._error:
392 points[i] = pt, segmentType, True, name, kwargs
393
394 for pt, segmentType, smooth, name, kwargs in points:
395 self._outPen.addPoint(pt, segmentType, smooth, name, **kwargs)
396
397 def beginPath(self, identifier=None, **kwargs):
398 if self._points is not None:
399 raise PenError("Path already begun")
400 self._points = []
401 if identifier is not None:
402 kwargs["identifier"] = identifier
403 self._outPen.beginPath(**kwargs)
404
405 def endPath(self):
406 self._flushContour()
407 self._outPen.endPath()
408 self._points = None
409
410 def addPoint(
411 self, pt, segmentType=None, smooth=False, name=None, identifier=None, **kwargs
412 ):
413 if self._points is None:
414 raise PenError("Path not begun")
415 if identifier is not None:
416 kwargs["identifier"] = identifier
417 self._points.append((pt, segmentType, False, name, kwargs))
418
419 def addComponent(self, glyphName, transformation, identifier=None, **kwargs):
420 if self._points is not None:
421 raise PenError("Components must be added before or after contours")
422 if identifier is not None:
423 kwargs["identifier"] = identifier
424 self._outPen.addComponent(glyphName, transformation, **kwargs)
425
426 def addVarComponent(
427 self, glyphName, transformation, location, identifier=None, **kwargs
428 ):
429 if self._points is not None:
430 raise PenError("VarComponents must be added before or after contours")
431 if identifier is not None:
432 kwargs["identifier"] = identifier
433 self._outPen.addVarComponent(glyphName, transformation, location, **kwargs)
434
435
436class ReverseContourPointPen(AbstractPointPen):
437 """
438 This is a PointPen that passes outline data to another PointPen, but
439 reversing the winding direction of all contours. Components are simply
440 passed through unchanged.
441
442 Closed contours are reversed in such a way that the first point remains
443 the first point.
444 """
445
446 def __init__(self, outputPointPen):
447 self.pen = outputPointPen
448 # a place to store the points for the current sub path
449 self.currentContour = None
450
451 def _flushContour(self):
452 pen = self.pen
453 contour = self.currentContour
454 if not contour:
455 pen.beginPath(identifier=self.currentContourIdentifier)
456 pen.endPath()
457 return
458
459 closed = contour[0][1] != "move"
460 if not closed:
461 lastSegmentType = "move"
462 else:
463 # Remove the first point and insert it at the end. When
464 # the list of points gets reversed, this point will then
465 # again be at the start. In other words, the following
466 # will hold:
467 # for N in range(len(originalContour)):
468 # originalContour[N] == reversedContour[-N]
469 contour.append(contour.pop(0))
470 # Find the first on-curve point.
471 firstOnCurve = None
472 for i in range(len(contour)):
473 if contour[i][1] is not None:
474 firstOnCurve = i
475 break
476 if firstOnCurve is None:
477 # There are no on-curve points, be basically have to
478 # do nothing but contour.reverse().
479 lastSegmentType = None
480 else:
481 lastSegmentType = contour[firstOnCurve][1]
482
483 contour.reverse()
484 if not closed:
485 # Open paths must start with a move, so we simply dump
486 # all off-curve points leading up to the first on-curve.
487 while contour[0][1] is None:
488 contour.pop(0)
489 pen.beginPath(identifier=self.currentContourIdentifier)
490 for pt, nextSegmentType, smooth, name, kwargs in contour:
491 if nextSegmentType is not None:
492 segmentType = lastSegmentType
493 lastSegmentType = nextSegmentType
494 else:
495 segmentType = None
496 pen.addPoint(
497 pt, segmentType=segmentType, smooth=smooth, name=name, **kwargs
498 )
499 pen.endPath()
500
501 def beginPath(self, identifier=None, **kwargs):
502 if self.currentContour is not None:
503 raise PenError("Path already begun")
504 self.currentContour = []
505 self.currentContourIdentifier = identifier
506 self.onCurve = []
507
508 def endPath(self):
509 if self.currentContour is None:
510 raise PenError("Path not begun")
511 self._flushContour()
512 self.currentContour = None
513
514 def addPoint(
515 self, pt, segmentType=None, smooth=False, name=None, identifier=None, **kwargs
516 ):
517 if self.currentContour is None:
518 raise PenError("Path not begun")
519 if identifier is not None:
520 kwargs["identifier"] = identifier
521 self.currentContour.append((pt, segmentType, smooth, name, kwargs))
522
523 def addComponent(self, glyphName, transform, identifier=None, **kwargs):
524 if self.currentContour is not None:
525 raise PenError("Components must be added before or after contours")
526 self.pen.addComponent(glyphName, transform, identifier=identifier, **kwargs)
527
528
529class DecomposingPointPen(LogMixin, AbstractPointPen):
530 """Implements a 'addComponent' method that decomposes components
531 (i.e. draws them onto self as simple contours).
532 It can also be used as a mixin class (e.g. see DecomposingRecordingPointPen).
533
534 You must override beginPath, addPoint, endPath. You may
535 additionally override addVarComponent and addComponent.
536
537 By default a warning message is logged when a base glyph is missing;
538 set the class variable ``skipMissingComponents`` to False if you want
539 all instances of a sub-class to raise a :class:`MissingComponentError`
540 exception by default.
541 """
542
543 skipMissingComponents = True
544 # alias error for convenience
545 MissingComponentError = MissingComponentError
546
547 def __init__(
548 self,
549 glyphSet,
550 *args,
551 skipMissingComponents=None,
552 reverseFlipped=False,
553 **kwargs,
554 ):
555 """Takes a 'glyphSet' argument (dict), in which the glyphs that are referenced
556 as components are looked up by their name.
557
558 If the optional 'reverseFlipped' argument is True, components whose transformation
559 matrix has a negative determinant will be decomposed with a reversed path direction
560 to compensate for the flip.
561
562 The optional 'skipMissingComponents' argument can be set to True/False to
563 override the homonymous class attribute for a given pen instance.
564 """
565 super().__init__(*args, **kwargs)
566 self.glyphSet = glyphSet
567 self.skipMissingComponents = (
568 self.__class__.skipMissingComponents
569 if skipMissingComponents is None
570 else skipMissingComponents
571 )
572 self.reverseFlipped = reverseFlipped
573
574 def addComponent(self, baseGlyphName, transformation, identifier=None, **kwargs):
575 """Transform the points of the base glyph and draw it onto self.
576
577 The `identifier` parameter and any extra kwargs are ignored.
578 """
579 from fontTools.pens.transformPen import TransformPointPen
580
581 try:
582 glyph = self.glyphSet[baseGlyphName]
583 except KeyError:
584 if not self.skipMissingComponents:
585 raise MissingComponentError(baseGlyphName)
586 self.log.warning(
587 "glyph '%s' is missing from glyphSet; skipped" % baseGlyphName
588 )
589 else:
590 pen = self
591 if transformation != Identity:
592 pen = TransformPointPen(pen, transformation)
593 if self.reverseFlipped:
594 # if the transformation has a negative determinant, it will
595 # reverse the contour direction of the component
596 a, b, c, d = transformation[:4]
597 det = a * d - b * c
598 if a * d - b * c < 0:
599 pen = ReverseContourPointPen(pen)
600 glyph.drawPoints(pen)