Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/fontTools/pens/pointPen.py: 19%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

336 statements  

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.enumTools import StrEnum 

21from fontTools.misc.loggingTools import LogMixin 

22from fontTools.misc.transform import DecomposedTransform, Identity 

23from fontTools.pens.basePen import AbstractPen, MissingComponentError, PenError 

24 

25__all__ = [ 

26 "AbstractPointPen", 

27 "BasePointToSegmentPen", 

28 "PointToSegmentPen", 

29 "SegmentToPointPen", 

30 "GuessSmoothPointPen", 

31 "ReverseContourPointPen", 

32 "ReverseFlipped", 

33] 

34 

35# Some type aliases to make it easier below 

36Point = Tuple[float, float] 

37PointName = Optional[str] 

38# [(pt, smooth, name, kwargs)] 

39SegmentPointList = List[Tuple[Optional[Point], bool, PointName, Any]] 

40SegmentType = Optional[str] 

41SegmentList = List[Tuple[SegmentType, SegmentPointList]] 

42 

43 

44class ReverseFlipped(StrEnum): 

45 """How to handle flipped components during decomposition. 

46 

47 NO: Don't reverse flipped components 

48 KEEP_START: Reverse flipped components, keeping original starting point 

49 ON_CURVE_FIRST: Reverse flipped components, ensuring first point is on-curve 

50 """ 

51 

52 NO = "no" 

53 KEEP_START = "keep_start" 

54 ON_CURVE_FIRST = "on_curve_first" 

55 

56 

57class AbstractPointPen: 

58 """Baseclass for all PointPens.""" 

59 

60 def beginPath(self, identifier: Optional[str] = None, **kwargs: Any) -> None: 

61 """Start a new sub path.""" 

62 raise NotImplementedError 

63 

64 def endPath(self) -> None: 

65 """End the current sub path.""" 

66 raise NotImplementedError 

67 

68 def addPoint( 

69 self, 

70 pt: Tuple[float, float], 

71 segmentType: Optional[str] = None, 

72 smooth: bool = False, 

73 name: Optional[str] = None, 

74 identifier: Optional[str] = None, 

75 **kwargs: Any, 

76 ) -> None: 

77 """Add a point to the current sub path.""" 

78 raise NotImplementedError 

79 

80 def addComponent( 

81 self, 

82 baseGlyphName: str, 

83 transformation: Tuple[float, float, float, float, float, float], 

84 identifier: Optional[str] = None, 

85 **kwargs: Any, 

86 ) -> None: 

87 """Add a sub glyph.""" 

88 raise NotImplementedError 

89 

90 def addVarComponent( 

91 self, 

92 glyphName: str, 

93 transformation: DecomposedTransform, 

94 location: Dict[str, float], 

95 identifier: Optional[str] = None, 

96 **kwargs: Any, 

97 ) -> None: 

98 """Add a VarComponent sub glyph. The 'transformation' argument 

99 must be a DecomposedTransform from the fontTools.misc.transform module, 

100 and the 'location' argument must be a dictionary mapping axis tags 

101 to their locations. 

102 """ 

103 # ttGlyphSet decomposes for us 

104 raise AttributeError 

105 

106 

107class BasePointToSegmentPen(AbstractPointPen): 

108 """ 

109 Base class for retrieving the outline in a segment-oriented 

110 way. The PointPen protocol is simple yet also a little tricky, 

111 so when you need an outline presented as segments but you have 

112 as points, do use this base implementation as it properly takes 

113 care of all the edge cases. 

114 """ 

115 

116 def __init__(self) -> None: 

117 self.currentPath = None 

118 

119 def beginPath(self, identifier=None, **kwargs): 

120 if self.currentPath is not None: 

121 raise PenError("Path already begun.") 

122 self.currentPath = [] 

123 

124 def _flushContour(self, segments: SegmentList) -> None: 

125 """Override this method. 

126 

127 It will be called for each non-empty sub path with a list 

128 of segments: the 'segments' argument. 

129 

130 The segments list contains tuples of length 2: 

131 (segmentType, points) 

132 

133 segmentType is one of "move", "line", "curve" or "qcurve". 

134 "move" may only occur as the first segment, and it signifies 

135 an OPEN path. A CLOSED path does NOT start with a "move", in 

136 fact it will not contain a "move" at ALL. 

137 

138 The 'points' field in the 2-tuple is a list of point info 

139 tuples. The list has 1 or more items, a point tuple has 

140 four items: 

141 (point, smooth, name, kwargs) 

142 'point' is an (x, y) coordinate pair. 

143 

144 For a closed path, the initial moveTo point is defined as 

145 the last point of the last segment. 

146 

147 The 'points' list of "move" and "line" segments always contains 

148 exactly one point tuple. 

149 """ 

150 raise NotImplementedError 

151 

152 def endPath(self) -> None: 

153 if self.currentPath is None: 

154 raise PenError("Path not begun.") 

155 points = self.currentPath 

156 self.currentPath = None 

157 if not points: 

158 return 

159 if len(points) == 1: 

160 # Not much more we can do than output a single move segment. 

161 pt, segmentType, smooth, name, kwargs = points[0] 

162 segments: SegmentList = [("move", [(pt, smooth, name, kwargs)])] 

163 self._flushContour(segments) 

164 return 

165 segments = [] 

166 if points[0][1] == "move": 

167 # It's an open contour, insert a "move" segment for the first 

168 # point and remove that first point from the point list. 

169 pt, segmentType, smooth, name, kwargs = points[0] 

170 segments.append(("move", [(pt, smooth, name, kwargs)])) 

171 points.pop(0) 

172 else: 

173 # It's a closed contour. Locate the first on-curve point, and 

174 # rotate the point list so that it _ends_ with an on-curve 

175 # point. 

176 firstOnCurve = None 

177 for i in range(len(points)): 

178 segmentType = points[i][1] 

179 if segmentType is not None: 

180 firstOnCurve = i 

181 break 

182 if firstOnCurve is None: 

183 # Special case for quadratics: a contour with no on-curve 

184 # points. Add a "None" point. (See also the Pen protocol's 

185 # qCurveTo() method and fontTools.pens.basePen.py.) 

186 points.append((None, "qcurve", None, None, None)) 

187 else: 

188 points = points[firstOnCurve + 1 :] + points[: firstOnCurve + 1] 

189 

190 currentSegment: SegmentPointList = [] 

191 for pt, segmentType, smooth, name, kwargs in points: 

192 currentSegment.append((pt, smooth, name, kwargs)) 

193 if segmentType is None: 

194 continue 

195 segments.append((segmentType, currentSegment)) 

196 currentSegment = [] 

197 

198 self._flushContour(segments) 

199 

200 def addPoint( 

201 self, pt, segmentType=None, smooth=False, name=None, identifier=None, **kwargs 

202 ): 

203 if self.currentPath is None: 

204 raise PenError("Path not begun") 

205 self.currentPath.append((pt, segmentType, smooth, name, kwargs)) 

206 

207 

208class PointToSegmentPen(BasePointToSegmentPen): 

209 """ 

210 Adapter class that converts the PointPen protocol to the 

211 (Segment)Pen protocol. 

212 

213 NOTE: The segment pen does not support and will drop point names, identifiers 

214 and kwargs. 

215 """ 

216 

217 def __init__(self, segmentPen, outputImpliedClosingLine: bool = False) -> None: 

218 BasePointToSegmentPen.__init__(self) 

219 self.pen = segmentPen 

220 self.outputImpliedClosingLine = outputImpliedClosingLine 

221 

222 def _flushContour(self, segments): 

223 if not segments: 

224 raise PenError("Must have at least one segment.") 

225 pen = self.pen 

226 if segments[0][0] == "move": 

227 # It's an open path. 

228 closed = False 

229 points = segments[0][1] 

230 if len(points) != 1: 

231 raise PenError(f"Illegal move segment point count: {len(points)}") 

232 movePt, _, _, _ = points[0] 

233 del segments[0] 

234 else: 

235 # It's a closed path, do a moveTo to the last 

236 # point of the last segment. 

237 closed = True 

238 segmentType, points = segments[-1] 

239 movePt, _, _, _ = points[-1] 

240 if movePt is None: 

241 # quad special case: a contour with no on-curve points contains 

242 # one "qcurve" segment that ends with a point that's None. We 

243 # must not output a moveTo() in that case. 

244 pass 

245 else: 

246 pen.moveTo(movePt) 

247 outputImpliedClosingLine = self.outputImpliedClosingLine 

248 nSegments = len(segments) 

249 lastPt = movePt 

250 for i in range(nSegments): 

251 segmentType, points = segments[i] 

252 points = [pt for pt, _, _, _ in points] 

253 if segmentType == "line": 

254 if len(points) != 1: 

255 raise PenError(f"Illegal line segment point count: {len(points)}") 

256 pt = points[0] 

257 # For closed contours, a 'lineTo' is always implied from the last oncurve 

258 # point to the starting point, thus we can omit it when the last and 

259 # starting point don't overlap. 

260 # However, when the last oncurve point is a "line" segment and has same 

261 # coordinates as the starting point of a closed contour, we need to output 

262 # the closing 'lineTo' explicitly (regardless of the value of the 

263 # 'outputImpliedClosingLine' option) in order to disambiguate this case from 

264 # the implied closing 'lineTo', otherwise the duplicate point would be lost. 

265 # See https://github.com/googlefonts/fontmake/issues/572. 

266 if ( 

267 i + 1 != nSegments 

268 or outputImpliedClosingLine 

269 or not closed 

270 or pt == lastPt 

271 ): 

272 pen.lineTo(pt) 

273 lastPt = pt 

274 elif segmentType == "curve": 

275 pen.curveTo(*points) 

276 lastPt = points[-1] 

277 elif segmentType == "qcurve": 

278 pen.qCurveTo(*points) 

279 lastPt = points[-1] 

280 else: 

281 raise PenError(f"Illegal segmentType: {segmentType}") 

282 if closed: 

283 pen.closePath() 

284 else: 

285 pen.endPath() 

286 

287 def addComponent(self, glyphName, transform, identifier=None, **kwargs): 

288 del identifier # unused 

289 del kwargs # unused 

290 self.pen.addComponent(glyphName, transform) 

291 

292 

293class SegmentToPointPen(AbstractPen): 

294 """ 

295 Adapter class that converts the (Segment)Pen protocol to the 

296 PointPen protocol. 

297 """ 

298 

299 def __init__(self, pointPen, guessSmooth=True) -> None: 

300 if guessSmooth: 

301 self.pen = GuessSmoothPointPen(pointPen) 

302 else: 

303 self.pen = pointPen 

304 self.contour: Optional[List[Tuple[Point, SegmentType]]] = None 

305 

306 def _flushContour(self) -> None: 

307 pen = self.pen 

308 pen.beginPath() 

309 for pt, segmentType in self.contour: 

310 pen.addPoint(pt, segmentType=segmentType) 

311 pen.endPath() 

312 

313 def moveTo(self, pt): 

314 self.contour = [] 

315 self.contour.append((pt, "move")) 

316 

317 def lineTo(self, pt): 

318 if self.contour is None: 

319 raise PenError("Contour missing required initial moveTo") 

320 self.contour.append((pt, "line")) 

321 

322 def curveTo(self, *pts): 

323 if not pts: 

324 raise TypeError("Must pass in at least one point") 

325 if self.contour is None: 

326 raise PenError("Contour missing required initial moveTo") 

327 for pt in pts[:-1]: 

328 self.contour.append((pt, None)) 

329 self.contour.append((pts[-1], "curve")) 

330 

331 def qCurveTo(self, *pts): 

332 if not pts: 

333 raise TypeError("Must pass in at least one point") 

334 if pts[-1] is None: 

335 self.contour = [] 

336 else: 

337 if self.contour is None: 

338 raise PenError("Contour missing required initial moveTo") 

339 for pt in pts[:-1]: 

340 self.contour.append((pt, None)) 

341 if pts[-1] is not None: 

342 self.contour.append((pts[-1], "qcurve")) 

343 

344 def closePath(self): 

345 if self.contour is None: 

346 raise PenError("Contour missing required initial moveTo") 

347 

348 # Remove the last point if it's a duplicate of the first, but only if both 

349 # are on-curve points (segmentType is not None); for quad blobs 

350 # (all off-curve) every point must be preserved: 

351 # https://github.com/fonttools/fonttools/issues/4014 

352 if ( 

353 len(self.contour) > 1 

354 and (self.contour[0][0] == self.contour[-1][0]) 

355 and self.contour[0][1] is not None 

356 and self.contour[-1][1] is not None 

357 ): 

358 self.contour[0] = self.contour[-1] 

359 del self.contour[-1] 

360 else: 

361 # There's an implied line at the end, replace "move" with "line" 

362 # for the first point 

363 pt, tp = self.contour[0] 

364 if tp == "move": 

365 self.contour[0] = pt, "line" 

366 self._flushContour() 

367 self.contour = None 

368 

369 def endPath(self): 

370 if self.contour is None: 

371 raise PenError("Contour missing required initial moveTo") 

372 self._flushContour() 

373 self.contour = None 

374 

375 def addComponent(self, glyphName, transform): 

376 if self.contour is not None: 

377 raise PenError("Components must be added before or after contours") 

378 self.pen.addComponent(glyphName, transform) 

379 

380 

381class GuessSmoothPointPen(AbstractPointPen): 

382 """ 

383 Filtering PointPen that tries to determine whether an on-curve point 

384 should be "smooth", ie. that it's a "tangent" point or a "curve" point. 

385 """ 

386 

387 def __init__(self, outPen, error=0.05): 

388 self._outPen = outPen 

389 self._error = error 

390 self._points = None 

391 

392 def _flushContour(self): 

393 if self._points is None: 

394 raise PenError("Path not begun") 

395 points = self._points 

396 nPoints = len(points) 

397 if not nPoints: 

398 return 

399 if points[0][1] == "move": 

400 # Open path. 

401 indices = range(1, nPoints - 1) 

402 elif nPoints > 1: 

403 # Closed path. To avoid having to mod the contour index, we 

404 # simply abuse Python's negative index feature, and start at -1 

405 indices = range(-1, nPoints - 1) 

406 else: 

407 # closed path containing 1 point (!), ignore. 

408 indices = [] 

409 for i in indices: 

410 pt, segmentType, _, name, kwargs = points[i] 

411 if segmentType is None: 

412 continue 

413 prev = i - 1 

414 next = i + 1 

415 if points[prev][1] is not None and points[next][1] is not None: 

416 continue 

417 # At least one of our neighbors is an off-curve point 

418 pt = points[i][0] 

419 prevPt = points[prev][0] 

420 nextPt = points[next][0] 

421 if pt != prevPt and pt != nextPt: 

422 dx1, dy1 = pt[0] - prevPt[0], pt[1] - prevPt[1] 

423 dx2, dy2 = nextPt[0] - pt[0], nextPt[1] - pt[1] 

424 a1 = math.atan2(dy1, dx1) 

425 a2 = math.atan2(dy2, dx2) 

426 if abs(a1 - a2) < self._error: 

427 points[i] = pt, segmentType, True, name, kwargs 

428 

429 for pt, segmentType, smooth, name, kwargs in points: 

430 self._outPen.addPoint(pt, segmentType, smooth, name, **kwargs) 

431 

432 def beginPath(self, identifier=None, **kwargs): 

433 if self._points is not None: 

434 raise PenError("Path already begun") 

435 self._points = [] 

436 if identifier is not None: 

437 kwargs["identifier"] = identifier 

438 self._outPen.beginPath(**kwargs) 

439 

440 def endPath(self): 

441 self._flushContour() 

442 self._outPen.endPath() 

443 self._points = None 

444 

445 def addPoint( 

446 self, pt, segmentType=None, smooth=False, name=None, identifier=None, **kwargs 

447 ): 

448 if self._points is None: 

449 raise PenError("Path not begun") 

450 if identifier is not None: 

451 kwargs["identifier"] = identifier 

452 self._points.append((pt, segmentType, False, name, kwargs)) 

453 

454 def addComponent(self, glyphName, transformation, identifier=None, **kwargs): 

455 if self._points is not None: 

456 raise PenError("Components must be added before or after contours") 

457 if identifier is not None: 

458 kwargs["identifier"] = identifier 

459 self._outPen.addComponent(glyphName, transformation, **kwargs) 

460 

461 def addVarComponent( 

462 self, glyphName, transformation, location, identifier=None, **kwargs 

463 ): 

464 if self._points is not None: 

465 raise PenError("VarComponents must be added before or after contours") 

466 if identifier is not None: 

467 kwargs["identifier"] = identifier 

468 self._outPen.addVarComponent(glyphName, transformation, location, **kwargs) 

469 

470 

471class ReverseContourPointPen(AbstractPointPen): 

472 """ 

473 This is a PointPen that passes outline data to another PointPen, but 

474 reversing the winding direction of all contours. Components are simply 

475 passed through unchanged. 

476 

477 Closed contours are reversed in such a way that the first point remains 

478 the first point. 

479 """ 

480 

481 def __init__(self, outputPointPen): 

482 self.pen = outputPointPen 

483 # a place to store the points for the current sub path 

484 self.currentContour = None 

485 

486 def _flushContour(self): 

487 pen = self.pen 

488 contour = self.currentContour 

489 if not contour: 

490 pen.beginPath(identifier=self.currentContourIdentifier) 

491 pen.endPath() 

492 return 

493 

494 closed = contour[0][1] != "move" 

495 if not closed: 

496 lastSegmentType = "move" 

497 else: 

498 # Remove the first point and insert it at the end. When 

499 # the list of points gets reversed, this point will then 

500 # again be at the start. In other words, the following 

501 # will hold: 

502 # for N in range(len(originalContour)): 

503 # originalContour[N] == reversedContour[-N] 

504 contour.append(contour.pop(0)) 

505 # Find the first on-curve point. 

506 firstOnCurve = None 

507 for i in range(len(contour)): 

508 if contour[i][1] is not None: 

509 firstOnCurve = i 

510 break 

511 if firstOnCurve is None: 

512 # There are no on-curve points, be basically have to 

513 # do nothing but contour.reverse(). 

514 lastSegmentType = None 

515 else: 

516 lastSegmentType = contour[firstOnCurve][1] 

517 

518 contour.reverse() 

519 if not closed: 

520 # Open paths must start with a move, so we simply dump 

521 # all off-curve points leading up to the first on-curve. 

522 while contour[0][1] is None: 

523 contour.pop(0) 

524 pen.beginPath(identifier=self.currentContourIdentifier) 

525 for pt, nextSegmentType, smooth, name, kwargs in contour: 

526 if nextSegmentType is not None: 

527 segmentType = lastSegmentType 

528 lastSegmentType = nextSegmentType 

529 else: 

530 segmentType = None 

531 pen.addPoint( 

532 pt, segmentType=segmentType, smooth=smooth, name=name, **kwargs 

533 ) 

534 pen.endPath() 

535 

536 def beginPath(self, identifier=None, **kwargs): 

537 if self.currentContour is not None: 

538 raise PenError("Path already begun") 

539 self.currentContour = [] 

540 self.currentContourIdentifier = identifier 

541 self.onCurve = [] 

542 

543 def endPath(self): 

544 if self.currentContour is None: 

545 raise PenError("Path not begun") 

546 self._flushContour() 

547 self.currentContour = None 

548 

549 def addPoint( 

550 self, pt, segmentType=None, smooth=False, name=None, identifier=None, **kwargs 

551 ): 

552 if self.currentContour is None: 

553 raise PenError("Path not begun") 

554 if identifier is not None: 

555 kwargs["identifier"] = identifier 

556 self.currentContour.append((pt, segmentType, smooth, name, kwargs)) 

557 

558 def addComponent(self, glyphName, transform, identifier=None, **kwargs): 

559 if self.currentContour is not None: 

560 raise PenError("Components must be added before or after contours") 

561 self.pen.addComponent(glyphName, transform, identifier=identifier, **kwargs) 

562 

563 

564class DecomposingPointPen(LogMixin, AbstractPointPen): 

565 """Implements a 'addComponent' method that decomposes components 

566 (i.e. draws them onto self as simple contours). 

567 It can also be used as a mixin class (e.g. see DecomposingRecordingPointPen). 

568 

569 You must override beginPath, addPoint, endPath. You may 

570 additionally override addVarComponent and addComponent. 

571 

572 By default a warning message is logged when a base glyph is missing; 

573 set the class variable ``skipMissingComponents`` to False if you want 

574 all instances of a sub-class to raise a :class:`MissingComponentError` 

575 exception by default. 

576 """ 

577 

578 skipMissingComponents = True 

579 # alias error for convenience 

580 MissingComponentError = MissingComponentError 

581 

582 def __init__( 

583 self, 

584 glyphSet, 

585 *args, 

586 skipMissingComponents=None, 

587 reverseFlipped: bool | ReverseFlipped = False, 

588 **kwargs, 

589 ): 

590 """Takes a 'glyphSet' argument (dict), in which the glyphs that are referenced 

591 as components are looked up by their name. 

592 

593 If the optional 'reverseFlipped' argument is True or a ReverseFlipped enum value, 

594 components whose transformation matrix has a negative determinant will be decomposed 

595 with a reversed path direction to compensate for the flip. 

596 

597 The reverseFlipped parameter can be: 

598 - False or ReverseFlipped.NO: Don't reverse flipped components 

599 - True or ReverseFlipped.KEEP_START: Reverse, keeping original starting point 

600 - ReverseFlipped.ON_CURVE_FIRST: Reverse, ensuring first point is on-curve 

601 

602 The optional 'skipMissingComponents' argument can be set to True/False to 

603 override the homonymous class attribute for a given pen instance. 

604 """ 

605 super().__init__(*args, **kwargs) 

606 self.glyphSet = glyphSet 

607 self.skipMissingComponents = ( 

608 self.__class__.skipMissingComponents 

609 if skipMissingComponents is None 

610 else skipMissingComponents 

611 ) 

612 # Handle backward compatibility and validate string inputs 

613 if reverseFlipped is False: 

614 self.reverseFlipped = ReverseFlipped.NO 

615 elif reverseFlipped is True: 

616 self.reverseFlipped = ReverseFlipped.KEEP_START 

617 else: 

618 self.reverseFlipped = ReverseFlipped(reverseFlipped) 

619 

620 def addComponent(self, baseGlyphName, transformation, identifier=None, **kwargs): 

621 """Transform the points of the base glyph and draw it onto self. 

622 

623 The `identifier` parameter and any extra kwargs are ignored. 

624 """ 

625 from fontTools.pens.transformPen import TransformPointPen 

626 

627 try: 

628 glyph = self.glyphSet[baseGlyphName] 

629 except KeyError: 

630 if not self.skipMissingComponents: 

631 raise MissingComponentError(baseGlyphName) 

632 self.log.warning( 

633 "glyph '%s' is missing from glyphSet; skipped" % baseGlyphName 

634 ) 

635 else: 

636 pen = self 

637 if transformation != Identity: 

638 pen = TransformPointPen(pen, transformation) 

639 if self.reverseFlipped != ReverseFlipped.NO: 

640 # if the transformation has a negative determinant, it will 

641 # reverse the contour direction of the component 

642 a, b, c, d = transformation[:4] 

643 if a * d - b * c < 0: 

644 pen = ReverseContourPointPen(pen) 

645 

646 if self.reverseFlipped == ReverseFlipped.ON_CURVE_FIRST: 

647 from fontTools.pens.filterPen import OnCurveFirstPointPen 

648 

649 # Ensure the starting point is an on-curve. 

650 # Wrap last so this filter runs first during drawPoints 

651 pen = OnCurveFirstPointPen(pen) 

652 

653 glyph.drawPoints(pen)