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 if len(self.contour) > 1 and self.contour[0][0] == self.contour[-1][0]: 

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

349 del self.contour[-1] 

350 else: 

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

352 # for the first point 

353 pt, tp = self.contour[0] 

354 if tp == "move": 

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

356 self._flushContour() 

357 self.contour = None 

358 

359 def endPath(self): 

360 if self.contour is None: 

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

362 self._flushContour() 

363 self.contour = None 

364 

365 def addComponent(self, glyphName, transform): 

366 if self.contour is not None: 

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

368 self.pen.addComponent(glyphName, transform) 

369 

370 

371class GuessSmoothPointPen(AbstractPointPen): 

372 """ 

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

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

375 """ 

376 

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

378 self._outPen = outPen 

379 self._error = error 

380 self._points = None 

381 

382 def _flushContour(self): 

383 if self._points is None: 

384 raise PenError("Path not begun") 

385 points = self._points 

386 nPoints = len(points) 

387 if not nPoints: 

388 return 

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

390 # Open path. 

391 indices = range(1, nPoints - 1) 

392 elif nPoints > 1: 

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

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

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

396 else: 

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

398 indices = [] 

399 for i in indices: 

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

401 if segmentType is None: 

402 continue 

403 prev = i - 1 

404 next = i + 1 

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

406 continue 

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

408 pt = points[i][0] 

409 prevPt = points[prev][0] 

410 nextPt = points[next][0] 

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

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

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

414 a1 = math.atan2(dy1, dx1) 

415 a2 = math.atan2(dy2, dx2) 

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

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

418 

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

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

421 

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

423 if self._points is not None: 

424 raise PenError("Path already begun") 

425 self._points = [] 

426 if identifier is not None: 

427 kwargs["identifier"] = identifier 

428 self._outPen.beginPath(**kwargs) 

429 

430 def endPath(self): 

431 self._flushContour() 

432 self._outPen.endPath() 

433 self._points = None 

434 

435 def addPoint( 

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

437 ): 

438 if self._points is None: 

439 raise PenError("Path not begun") 

440 if identifier is not None: 

441 kwargs["identifier"] = identifier 

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

443 

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

445 if self._points is not None: 

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

447 if identifier is not None: 

448 kwargs["identifier"] = identifier 

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

450 

451 def addVarComponent( 

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

453 ): 

454 if self._points is not None: 

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

456 if identifier is not None: 

457 kwargs["identifier"] = identifier 

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

459 

460 

461class ReverseContourPointPen(AbstractPointPen): 

462 """ 

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

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

465 passed through unchanged. 

466 

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

468 the first point. 

469 """ 

470 

471 def __init__(self, outputPointPen): 

472 self.pen = outputPointPen 

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

474 self.currentContour = None 

475 

476 def _flushContour(self): 

477 pen = self.pen 

478 contour = self.currentContour 

479 if not contour: 

480 pen.beginPath(identifier=self.currentContourIdentifier) 

481 pen.endPath() 

482 return 

483 

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

485 if not closed: 

486 lastSegmentType = "move" 

487 else: 

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

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

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

491 # will hold: 

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

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

494 contour.append(contour.pop(0)) 

495 # Find the first on-curve point. 

496 firstOnCurve = None 

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

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

499 firstOnCurve = i 

500 break 

501 if firstOnCurve is None: 

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

503 # do nothing but contour.reverse(). 

504 lastSegmentType = None 

505 else: 

506 lastSegmentType = contour[firstOnCurve][1] 

507 

508 contour.reverse() 

509 if not closed: 

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

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

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

513 contour.pop(0) 

514 pen.beginPath(identifier=self.currentContourIdentifier) 

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

516 if nextSegmentType is not None: 

517 segmentType = lastSegmentType 

518 lastSegmentType = nextSegmentType 

519 else: 

520 segmentType = None 

521 pen.addPoint( 

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

523 ) 

524 pen.endPath() 

525 

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

527 if self.currentContour is not None: 

528 raise PenError("Path already begun") 

529 self.currentContour = [] 

530 self.currentContourIdentifier = identifier 

531 self.onCurve = [] 

532 

533 def endPath(self): 

534 if self.currentContour is None: 

535 raise PenError("Path not begun") 

536 self._flushContour() 

537 self.currentContour = None 

538 

539 def addPoint( 

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

541 ): 

542 if self.currentContour is None: 

543 raise PenError("Path not begun") 

544 if identifier is not None: 

545 kwargs["identifier"] = identifier 

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

547 

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

549 if self.currentContour is not None: 

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

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

552 

553 

554class DecomposingPointPen(LogMixin, AbstractPointPen): 

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

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

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

558 

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

560 additionally override addVarComponent and addComponent. 

561 

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

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

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

565 exception by default. 

566 """ 

567 

568 skipMissingComponents = True 

569 # alias error for convenience 

570 MissingComponentError = MissingComponentError 

571 

572 def __init__( 

573 self, 

574 glyphSet, 

575 *args, 

576 skipMissingComponents=None, 

577 reverseFlipped: bool | ReverseFlipped = False, 

578 **kwargs, 

579 ): 

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

581 as components are looked up by their name. 

582 

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

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

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

586 

587 The reverseFlipped parameter can be: 

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

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

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

591 

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

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

594 """ 

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

596 self.glyphSet = glyphSet 

597 self.skipMissingComponents = ( 

598 self.__class__.skipMissingComponents 

599 if skipMissingComponents is None 

600 else skipMissingComponents 

601 ) 

602 # Handle backward compatibility and validate string inputs 

603 if reverseFlipped is False: 

604 self.reverseFlipped = ReverseFlipped.NO 

605 elif reverseFlipped is True: 

606 self.reverseFlipped = ReverseFlipped.KEEP_START 

607 else: 

608 self.reverseFlipped = ReverseFlipped(reverseFlipped) 

609 

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

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

612 

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

614 """ 

615 from fontTools.pens.transformPen import TransformPointPen 

616 

617 try: 

618 glyph = self.glyphSet[baseGlyphName] 

619 except KeyError: 

620 if not self.skipMissingComponents: 

621 raise MissingComponentError(baseGlyphName) 

622 self.log.warning( 

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

624 ) 

625 else: 

626 pen = self 

627 if transformation != Identity: 

628 pen = TransformPointPen(pen, transformation) 

629 if self.reverseFlipped != ReverseFlipped.NO: 

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

631 # reverse the contour direction of the component 

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

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

634 pen = ReverseContourPointPen(pen) 

635 

636 if self.reverseFlipped == ReverseFlipped.ON_CURVE_FIRST: 

637 from fontTools.pens.filterPen import OnCurveFirstPointPen 

638 

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

640 # Wrap last so this filter runs first during drawPoints 

641 pen = OnCurveFirstPointPen(pen) 

642 

643 glyph.drawPoints(pen)