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