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