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