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

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

324 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.loggingTools import LogMixin 

21from fontTools.misc.transform import DecomposedTransform, Identity 

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

23 

24__all__ = [ 

25 "AbstractPointPen", 

26 "BasePointToSegmentPen", 

27 "PointToSegmentPen", 

28 "SegmentToPointPen", 

29 "GuessSmoothPointPen", 

30 "ReverseContourPointPen", 

31] 

32 

33# Some type aliases to make it easier below 

34Point = Tuple[float, float] 

35PointName = Optional[str] 

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

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

38SegmentType = Optional[str] 

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

40 

41 

42class AbstractPointPen: 

43 """Baseclass for all PointPens.""" 

44 

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

46 """Start a new sub path.""" 

47 raise NotImplementedError 

48 

49 def endPath(self) -> None: 

50 """End the current sub path.""" 

51 raise NotImplementedError 

52 

53 def addPoint( 

54 self, 

55 pt: Tuple[float, float], 

56 segmentType: Optional[str] = None, 

57 smooth: bool = False, 

58 name: Optional[str] = None, 

59 identifier: Optional[str] = None, 

60 **kwargs: Any, 

61 ) -> None: 

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

63 raise NotImplementedError 

64 

65 def addComponent( 

66 self, 

67 baseGlyphName: str, 

68 transformation: Tuple[float, float, float, float, float, float], 

69 identifier: Optional[str] = None, 

70 **kwargs: Any, 

71 ) -> None: 

72 """Add a sub glyph.""" 

73 raise NotImplementedError 

74 

75 def addVarComponent( 

76 self, 

77 glyphName: str, 

78 transformation: DecomposedTransform, 

79 location: Dict[str, float], 

80 identifier: Optional[str] = None, 

81 **kwargs: Any, 

82 ) -> None: 

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

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

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

86 to their locations. 

87 """ 

88 # ttGlyphSet decomposes for us 

89 raise AttributeError 

90 

91 

92class BasePointToSegmentPen(AbstractPointPen): 

93 """ 

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

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

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

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

98 care of all the edge cases. 

99 """ 

100 

101 def __init__(self) -> None: 

102 self.currentPath = None 

103 

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

105 if self.currentPath is not None: 

106 raise PenError("Path already begun.") 

107 self.currentPath = [] 

108 

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

110 """Override this method. 

111 

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

113 of segments: the 'segments' argument. 

114 

115 The segments list contains tuples of length 2: 

116 (segmentType, points) 

117 

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

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

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

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

122 

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

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

125 four items: 

126 (point, smooth, name, kwargs) 

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

128 

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

130 the last point of the last segment. 

131 

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

133 exactly one point tuple. 

134 """ 

135 raise NotImplementedError 

136 

137 def endPath(self) -> None: 

138 if self.currentPath is None: 

139 raise PenError("Path not begun.") 

140 points = self.currentPath 

141 self.currentPath = None 

142 if not points: 

143 return 

144 if len(points) == 1: 

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

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

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

148 self._flushContour(segments) 

149 return 

150 segments = [] 

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

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

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

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

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

156 points.pop(0) 

157 else: 

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

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

160 # point. 

161 firstOnCurve = None 

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

163 segmentType = points[i][1] 

164 if segmentType is not None: 

165 firstOnCurve = i 

166 break 

167 if firstOnCurve is None: 

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

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

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

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

172 else: 

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

174 

175 currentSegment: SegmentPointList = [] 

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

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

178 if segmentType is None: 

179 continue 

180 segments.append((segmentType, currentSegment)) 

181 currentSegment = [] 

182 

183 self._flushContour(segments) 

184 

185 def addPoint( 

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

187 ): 

188 if self.currentPath is None: 

189 raise PenError("Path not begun") 

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

191 

192 

193class PointToSegmentPen(BasePointToSegmentPen): 

194 """ 

195 Adapter class that converts the PointPen protocol to the 

196 (Segment)Pen protocol. 

197 

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

199 and kwargs. 

200 """ 

201 

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

203 BasePointToSegmentPen.__init__(self) 

204 self.pen = segmentPen 

205 self.outputImpliedClosingLine = outputImpliedClosingLine 

206 

207 def _flushContour(self, segments): 

208 if not segments: 

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

210 pen = self.pen 

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

212 # It's an open path. 

213 closed = False 

214 points = segments[0][1] 

215 if len(points) != 1: 

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

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

218 del segments[0] 

219 else: 

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

221 # point of the last segment. 

222 closed = True 

223 segmentType, points = segments[-1] 

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

225 if movePt is None: 

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

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

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

229 pass 

230 else: 

231 pen.moveTo(movePt) 

232 outputImpliedClosingLine = self.outputImpliedClosingLine 

233 nSegments = len(segments) 

234 lastPt = movePt 

235 for i in range(nSegments): 

236 segmentType, points = segments[i] 

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

238 if segmentType == "line": 

239 if len(points) != 1: 

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

241 pt = points[0] 

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

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

244 # starting point don't overlap. 

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

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

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

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

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

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

251 if ( 

252 i + 1 != nSegments 

253 or outputImpliedClosingLine 

254 or not closed 

255 or pt == lastPt 

256 ): 

257 pen.lineTo(pt) 

258 lastPt = pt 

259 elif segmentType == "curve": 

260 pen.curveTo(*points) 

261 lastPt = points[-1] 

262 elif segmentType == "qcurve": 

263 pen.qCurveTo(*points) 

264 lastPt = points[-1] 

265 else: 

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

267 if closed: 

268 pen.closePath() 

269 else: 

270 pen.endPath() 

271 

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

273 del identifier # unused 

274 del kwargs # unused 

275 self.pen.addComponent(glyphName, transform) 

276 

277 

278class SegmentToPointPen(AbstractPen): 

279 """ 

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

281 PointPen protocol. 

282 """ 

283 

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

285 if guessSmooth: 

286 self.pen = GuessSmoothPointPen(pointPen) 

287 else: 

288 self.pen = pointPen 

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

290 

291 def _flushContour(self) -> None: 

292 pen = self.pen 

293 pen.beginPath() 

294 for pt, segmentType in self.contour: 

295 pen.addPoint(pt, segmentType=segmentType) 

296 pen.endPath() 

297 

298 def moveTo(self, pt): 

299 self.contour = [] 

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

301 

302 def lineTo(self, pt): 

303 if self.contour is None: 

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

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

306 

307 def curveTo(self, *pts): 

308 if not pts: 

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

310 if self.contour is None: 

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

312 for pt in pts[:-1]: 

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

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

315 

316 def qCurveTo(self, *pts): 

317 if not pts: 

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

319 if pts[-1] is None: 

320 self.contour = [] 

321 else: 

322 if self.contour is None: 

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

324 for pt in pts[:-1]: 

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

326 if pts[-1] is not None: 

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

328 

329 def closePath(self): 

330 if self.contour is None: 

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

332 if len(self.contour) > 1 and self.contour[0][0] == self.contour[-1][0]: 

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

334 del self.contour[-1] 

335 else: 

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

337 # for the first point 

338 pt, tp = self.contour[0] 

339 if tp == "move": 

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

341 self._flushContour() 

342 self.contour = None 

343 

344 def endPath(self): 

345 if self.contour is None: 

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

347 self._flushContour() 

348 self.contour = None 

349 

350 def addComponent(self, glyphName, transform): 

351 if self.contour is not None: 

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

353 self.pen.addComponent(glyphName, transform) 

354 

355 

356class GuessSmoothPointPen(AbstractPointPen): 

357 """ 

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

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

360 """ 

361 

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

363 self._outPen = outPen 

364 self._error = error 

365 self._points = None 

366 

367 def _flushContour(self): 

368 if self._points is None: 

369 raise PenError("Path not begun") 

370 points = self._points 

371 nPoints = len(points) 

372 if not nPoints: 

373 return 

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

375 # Open path. 

376 indices = range(1, nPoints - 1) 

377 elif nPoints > 1: 

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

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

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

381 else: 

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

383 indices = [] 

384 for i in indices: 

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

386 if segmentType is None: 

387 continue 

388 prev = i - 1 

389 next = i + 1 

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

391 continue 

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

393 pt = points[i][0] 

394 prevPt = points[prev][0] 

395 nextPt = points[next][0] 

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

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

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

399 a1 = math.atan2(dy1, dx1) 

400 a2 = math.atan2(dy2, dx2) 

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

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

403 

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

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

406 

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

408 if self._points is not None: 

409 raise PenError("Path already begun") 

410 self._points = [] 

411 if identifier is not None: 

412 kwargs["identifier"] = identifier 

413 self._outPen.beginPath(**kwargs) 

414 

415 def endPath(self): 

416 self._flushContour() 

417 self._outPen.endPath() 

418 self._points = None 

419 

420 def addPoint( 

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

422 ): 

423 if self._points is None: 

424 raise PenError("Path not begun") 

425 if identifier is not None: 

426 kwargs["identifier"] = identifier 

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

428 

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

430 if self._points is not None: 

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

432 if identifier is not None: 

433 kwargs["identifier"] = identifier 

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

435 

436 def addVarComponent( 

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

438 ): 

439 if self._points is not None: 

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

441 if identifier is not None: 

442 kwargs["identifier"] = identifier 

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

444 

445 

446class ReverseContourPointPen(AbstractPointPen): 

447 """ 

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

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

450 passed through unchanged. 

451 

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

453 the first point. 

454 """ 

455 

456 def __init__(self, outputPointPen): 

457 self.pen = outputPointPen 

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

459 self.currentContour = None 

460 

461 def _flushContour(self): 

462 pen = self.pen 

463 contour = self.currentContour 

464 if not contour: 

465 pen.beginPath(identifier=self.currentContourIdentifier) 

466 pen.endPath() 

467 return 

468 

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

470 if not closed: 

471 lastSegmentType = "move" 

472 else: 

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

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

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

476 # will hold: 

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

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

479 contour.append(contour.pop(0)) 

480 # Find the first on-curve point. 

481 firstOnCurve = None 

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

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

484 firstOnCurve = i 

485 break 

486 if firstOnCurve is None: 

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

488 # do nothing but contour.reverse(). 

489 lastSegmentType = None 

490 else: 

491 lastSegmentType = contour[firstOnCurve][1] 

492 

493 contour.reverse() 

494 if not closed: 

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

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

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

498 contour.pop(0) 

499 pen.beginPath(identifier=self.currentContourIdentifier) 

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

501 if nextSegmentType is not None: 

502 segmentType = lastSegmentType 

503 lastSegmentType = nextSegmentType 

504 else: 

505 segmentType = None 

506 pen.addPoint( 

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

508 ) 

509 pen.endPath() 

510 

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

512 if self.currentContour is not None: 

513 raise PenError("Path already begun") 

514 self.currentContour = [] 

515 self.currentContourIdentifier = identifier 

516 self.onCurve = [] 

517 

518 def endPath(self): 

519 if self.currentContour is None: 

520 raise PenError("Path not begun") 

521 self._flushContour() 

522 self.currentContour = None 

523 

524 def addPoint( 

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

526 ): 

527 if self.currentContour is None: 

528 raise PenError("Path not begun") 

529 if identifier is not None: 

530 kwargs["identifier"] = identifier 

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

532 

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

534 if self.currentContour is not None: 

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

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

537 

538 

539class DecomposingPointPen(LogMixin, AbstractPointPen): 

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

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

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

543 

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

545 additionally override addVarComponent and addComponent. 

546 

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

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

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

550 exception by default. 

551 """ 

552 

553 skipMissingComponents = True 

554 # alias error for convenience 

555 MissingComponentError = MissingComponentError 

556 

557 def __init__( 

558 self, 

559 glyphSet, 

560 *args, 

561 skipMissingComponents=None, 

562 reverseFlipped=False, 

563 **kwargs, 

564 ): 

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

566 as components are looked up by their name. 

567 

568 If the optional 'reverseFlipped' argument is True, components whose transformation 

569 matrix has a negative determinant will be decomposed with a reversed path direction 

570 to compensate for the flip. 

571 

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

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

574 """ 

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

576 self.glyphSet = glyphSet 

577 self.skipMissingComponents = ( 

578 self.__class__.skipMissingComponents 

579 if skipMissingComponents is None 

580 else skipMissingComponents 

581 ) 

582 self.reverseFlipped = reverseFlipped 

583 

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

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

586 

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

588 """ 

589 from fontTools.pens.transformPen import TransformPointPen 

590 

591 try: 

592 glyph = self.glyphSet[baseGlyphName] 

593 except KeyError: 

594 if not self.skipMissingComponents: 

595 raise MissingComponentError(baseGlyphName) 

596 self.log.warning( 

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

598 ) 

599 else: 

600 pen = self 

601 if transformation != Identity: 

602 pen = TransformPointPen(pen, transformation) 

603 if self.reverseFlipped: 

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

605 # reverse the contour direction of the component 

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

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

608 pen = ReverseContourPointPen(pen) 

609 glyph.drawPoints(pen)