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