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