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 if len(self.contour) > 1 and self.contour[0][0] == self.contour[-1][0]:
348 self.contour[0] = self.contour[-1]
349 del self.contour[-1]
350 else:
351 # There's an implied line at the end, replace "move" with "line"
352 # for the first point
353 pt, tp = self.contour[0]
354 if tp == "move":
355 self.contour[0] = pt, "line"
356 self._flushContour()
357 self.contour = None
358
359 def endPath(self):
360 if self.contour is None:
361 raise PenError("Contour missing required initial moveTo")
362 self._flushContour()
363 self.contour = None
364
365 def addComponent(self, glyphName, transform):
366 if self.contour is not None:
367 raise PenError("Components must be added before or after contours")
368 self.pen.addComponent(glyphName, transform)
369
370
371class GuessSmoothPointPen(AbstractPointPen):
372 """
373 Filtering PointPen that tries to determine whether an on-curve point
374 should be "smooth", ie. that it's a "tangent" point or a "curve" point.
375 """
376
377 def __init__(self, outPen, error=0.05):
378 self._outPen = outPen
379 self._error = error
380 self._points = None
381
382 def _flushContour(self):
383 if self._points is None:
384 raise PenError("Path not begun")
385 points = self._points
386 nPoints = len(points)
387 if not nPoints:
388 return
389 if points[0][1] == "move":
390 # Open path.
391 indices = range(1, nPoints - 1)
392 elif nPoints > 1:
393 # Closed path. To avoid having to mod the contour index, we
394 # simply abuse Python's negative index feature, and start at -1
395 indices = range(-1, nPoints - 1)
396 else:
397 # closed path containing 1 point (!), ignore.
398 indices = []
399 for i in indices:
400 pt, segmentType, _, name, kwargs = points[i]
401 if segmentType is None:
402 continue
403 prev = i - 1
404 next = i + 1
405 if points[prev][1] is not None and points[next][1] is not None:
406 continue
407 # At least one of our neighbors is an off-curve point
408 pt = points[i][0]
409 prevPt = points[prev][0]
410 nextPt = points[next][0]
411 if pt != prevPt and pt != nextPt:
412 dx1, dy1 = pt[0] - prevPt[0], pt[1] - prevPt[1]
413 dx2, dy2 = nextPt[0] - pt[0], nextPt[1] - pt[1]
414 a1 = math.atan2(dy1, dx1)
415 a2 = math.atan2(dy2, dx2)
416 if abs(a1 - a2) < self._error:
417 points[i] = pt, segmentType, True, name, kwargs
418
419 for pt, segmentType, smooth, name, kwargs in points:
420 self._outPen.addPoint(pt, segmentType, smooth, name, **kwargs)
421
422 def beginPath(self, identifier=None, **kwargs):
423 if self._points is not None:
424 raise PenError("Path already begun")
425 self._points = []
426 if identifier is not None:
427 kwargs["identifier"] = identifier
428 self._outPen.beginPath(**kwargs)
429
430 def endPath(self):
431 self._flushContour()
432 self._outPen.endPath()
433 self._points = None
434
435 def addPoint(
436 self, pt, segmentType=None, smooth=False, name=None, identifier=None, **kwargs
437 ):
438 if self._points is None:
439 raise PenError("Path not begun")
440 if identifier is not None:
441 kwargs["identifier"] = identifier
442 self._points.append((pt, segmentType, False, name, kwargs))
443
444 def addComponent(self, glyphName, transformation, identifier=None, **kwargs):
445 if self._points is not None:
446 raise PenError("Components must be added before or after contours")
447 if identifier is not None:
448 kwargs["identifier"] = identifier
449 self._outPen.addComponent(glyphName, transformation, **kwargs)
450
451 def addVarComponent(
452 self, glyphName, transformation, location, identifier=None, **kwargs
453 ):
454 if self._points is not None:
455 raise PenError("VarComponents must be added before or after contours")
456 if identifier is not None:
457 kwargs["identifier"] = identifier
458 self._outPen.addVarComponent(glyphName, transformation, location, **kwargs)
459
460
461class ReverseContourPointPen(AbstractPointPen):
462 """
463 This is a PointPen that passes outline data to another PointPen, but
464 reversing the winding direction of all contours. Components are simply
465 passed through unchanged.
466
467 Closed contours are reversed in such a way that the first point remains
468 the first point.
469 """
470
471 def __init__(self, outputPointPen):
472 self.pen = outputPointPen
473 # a place to store the points for the current sub path
474 self.currentContour = None
475
476 def _flushContour(self):
477 pen = self.pen
478 contour = self.currentContour
479 if not contour:
480 pen.beginPath(identifier=self.currentContourIdentifier)
481 pen.endPath()
482 return
483
484 closed = contour[0][1] != "move"
485 if not closed:
486 lastSegmentType = "move"
487 else:
488 # Remove the first point and insert it at the end. When
489 # the list of points gets reversed, this point will then
490 # again be at the start. In other words, the following
491 # will hold:
492 # for N in range(len(originalContour)):
493 # originalContour[N] == reversedContour[-N]
494 contour.append(contour.pop(0))
495 # Find the first on-curve point.
496 firstOnCurve = None
497 for i in range(len(contour)):
498 if contour[i][1] is not None:
499 firstOnCurve = i
500 break
501 if firstOnCurve is None:
502 # There are no on-curve points, be basically have to
503 # do nothing but contour.reverse().
504 lastSegmentType = None
505 else:
506 lastSegmentType = contour[firstOnCurve][1]
507
508 contour.reverse()
509 if not closed:
510 # Open paths must start with a move, so we simply dump
511 # all off-curve points leading up to the first on-curve.
512 while contour[0][1] is None:
513 contour.pop(0)
514 pen.beginPath(identifier=self.currentContourIdentifier)
515 for pt, nextSegmentType, smooth, name, kwargs in contour:
516 if nextSegmentType is not None:
517 segmentType = lastSegmentType
518 lastSegmentType = nextSegmentType
519 else:
520 segmentType = None
521 pen.addPoint(
522 pt, segmentType=segmentType, smooth=smooth, name=name, **kwargs
523 )
524 pen.endPath()
525
526 def beginPath(self, identifier=None, **kwargs):
527 if self.currentContour is not None:
528 raise PenError("Path already begun")
529 self.currentContour = []
530 self.currentContourIdentifier = identifier
531 self.onCurve = []
532
533 def endPath(self):
534 if self.currentContour is None:
535 raise PenError("Path not begun")
536 self._flushContour()
537 self.currentContour = None
538
539 def addPoint(
540 self, pt, segmentType=None, smooth=False, name=None, identifier=None, **kwargs
541 ):
542 if self.currentContour is None:
543 raise PenError("Path not begun")
544 if identifier is not None:
545 kwargs["identifier"] = identifier
546 self.currentContour.append((pt, segmentType, smooth, name, kwargs))
547
548 def addComponent(self, glyphName, transform, identifier=None, **kwargs):
549 if self.currentContour is not None:
550 raise PenError("Components must be added before or after contours")
551 self.pen.addComponent(glyphName, transform, identifier=identifier, **kwargs)
552
553
554class DecomposingPointPen(LogMixin, AbstractPointPen):
555 """Implements a 'addComponent' method that decomposes components
556 (i.e. draws them onto self as simple contours).
557 It can also be used as a mixin class (e.g. see DecomposingRecordingPointPen).
558
559 You must override beginPath, addPoint, endPath. You may
560 additionally override addVarComponent and addComponent.
561
562 By default a warning message is logged when a base glyph is missing;
563 set the class variable ``skipMissingComponents`` to False if you want
564 all instances of a sub-class to raise a :class:`MissingComponentError`
565 exception by default.
566 """
567
568 skipMissingComponents = True
569 # alias error for convenience
570 MissingComponentError = MissingComponentError
571
572 def __init__(
573 self,
574 glyphSet,
575 *args,
576 skipMissingComponents=None,
577 reverseFlipped: bool | ReverseFlipped = False,
578 **kwargs,
579 ):
580 """Takes a 'glyphSet' argument (dict), in which the glyphs that are referenced
581 as components are looked up by their name.
582
583 If the optional 'reverseFlipped' argument is True or a ReverseFlipped enum value,
584 components whose transformation matrix has a negative determinant will be decomposed
585 with a reversed path direction to compensate for the flip.
586
587 The reverseFlipped parameter can be:
588 - False or ReverseFlipped.NO: Don't reverse flipped components
589 - True or ReverseFlipped.KEEP_START: Reverse, keeping original starting point
590 - ReverseFlipped.ON_CURVE_FIRST: Reverse, ensuring first point is on-curve
591
592 The optional 'skipMissingComponents' argument can be set to True/False to
593 override the homonymous class attribute for a given pen instance.
594 """
595 super().__init__(*args, **kwargs)
596 self.glyphSet = glyphSet
597 self.skipMissingComponents = (
598 self.__class__.skipMissingComponents
599 if skipMissingComponents is None
600 else skipMissingComponents
601 )
602 # Handle backward compatibility and validate string inputs
603 if reverseFlipped is False:
604 self.reverseFlipped = ReverseFlipped.NO
605 elif reverseFlipped is True:
606 self.reverseFlipped = ReverseFlipped.KEEP_START
607 else:
608 self.reverseFlipped = ReverseFlipped(reverseFlipped)
609
610 def addComponent(self, baseGlyphName, transformation, identifier=None, **kwargs):
611 """Transform the points of the base glyph and draw it onto self.
612
613 The `identifier` parameter and any extra kwargs are ignored.
614 """
615 from fontTools.pens.transformPen import TransformPointPen
616
617 try:
618 glyph = self.glyphSet[baseGlyphName]
619 except KeyError:
620 if not self.skipMissingComponents:
621 raise MissingComponentError(baseGlyphName)
622 self.log.warning(
623 "glyph '%s' is missing from glyphSet; skipped" % baseGlyphName
624 )
625 else:
626 pen = self
627 if transformation != Identity:
628 pen = TransformPointPen(pen, transformation)
629 if self.reverseFlipped != ReverseFlipped.NO:
630 # if the transformation has a negative determinant, it will
631 # reverse the contour direction of the component
632 a, b, c, d = transformation[:4]
633 if a * d - b * c < 0:
634 pen = ReverseContourPointPen(pen)
635
636 if self.reverseFlipped == ReverseFlipped.ON_CURVE_FIRST:
637 from fontTools.pens.filterPen import OnCurveFirstPointPen
638
639 # Ensure the starting point is an on-curve.
640 # Wrap last so this filter runs first during drawPoints
641 pen = OnCurveFirstPointPen(pen)
642
643 glyph.drawPoints(pen)