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