1"""fontTools.pens.basePen.py -- Tools and base classes to build pen objects.
2
3The Pen Protocol
4
5A Pen is a kind of object that standardizes the way how to "draw" outlines:
6it is a middle man between an outline and a drawing. In other words:
7it is an abstraction for drawing outlines, making sure that outline objects
8don't need to know the details about how and where they're being drawn, and
9that drawings don't need to know the details of how outlines are stored.
10
11The most basic pattern is this::
12
13 outline.draw(pen) # 'outline' draws itself onto 'pen'
14
15Pens can be used to render outlines to the screen, but also to construct
16new outlines. Eg. an outline object can be both a drawable object (it has a
17draw() method) as well as a pen itself: you *build* an outline using pen
18methods.
19
20The AbstractPen class defines the Pen protocol. It implements almost
21nothing (only no-op closePath() and endPath() methods), but is useful
22for documentation purposes. Subclassing it basically tells the reader:
23"this class implements the Pen protocol.". An examples of an AbstractPen
24subclass is :py:class:`fontTools.pens.transformPen.TransformPen`.
25
26The BasePen class is a base implementation useful for pens that actually
27draw (for example a pen renders outlines using a native graphics engine).
28BasePen contains a lot of base functionality, making it very easy to build
29a pen that fully conforms to the pen protocol. Note that if you subclass
30BasePen, you *don't* override moveTo(), lineTo(), etc., but _moveTo(),
31_lineTo(), etc. See the BasePen doc string for details. Examples of
32BasePen subclasses are fontTools.pens.boundsPen.BoundsPen and
33fontTools.pens.cocoaPen.CocoaPen.
34
35Coordinates are usually expressed as (x, y) tuples, but generally any
36sequence of length 2 will do.
37"""
38
39from typing import Tuple, Dict
40
41from fontTools.misc.loggingTools import LogMixin
42from fontTools.misc.transform import DecomposedTransform, Identity
43
44__all__ = [
45 "AbstractPen",
46 "NullPen",
47 "BasePen",
48 "PenError",
49 "decomposeSuperBezierSegment",
50 "decomposeQuadraticSegment",
51]
52
53
54class PenError(Exception):
55 """Represents an error during penning."""
56
57
58class OpenContourError(PenError):
59 pass
60
61
62class AbstractPen:
63 def moveTo(self, pt: Tuple[float, float]) -> None:
64 """Begin a new sub path, set the current point to 'pt'. You must
65 end each sub path with a call to pen.closePath() or pen.endPath().
66 """
67 raise NotImplementedError
68
69 def lineTo(self, pt: Tuple[float, float]) -> None:
70 """Draw a straight line from the current point to 'pt'."""
71 raise NotImplementedError
72
73 def curveTo(self, *points: Tuple[float, float]) -> None:
74 """Draw a cubic bezier with an arbitrary number of control points.
75
76 The last point specified is on-curve, all others are off-curve
77 (control) points. If the number of control points is > 2, the
78 segment is split into multiple bezier segments. This works
79 like this:
80
81 Let n be the number of control points (which is the number of
82 arguments to this call minus 1). If n==2, a plain vanilla cubic
83 bezier is drawn. If n==1, we fall back to a quadratic segment and
84 if n==0 we draw a straight line. It gets interesting when n>2:
85 n-1 PostScript-style cubic segments will be drawn as if it were
86 one curve. See decomposeSuperBezierSegment().
87
88 The conversion algorithm used for n>2 is inspired by NURB
89 splines, and is conceptually equivalent to the TrueType "implied
90 points" principle. See also decomposeQuadraticSegment().
91 """
92 raise NotImplementedError
93
94 def qCurveTo(self, *points: Tuple[float, float]) -> None:
95 """Draw a whole string of quadratic curve segments.
96
97 The last point specified is on-curve, all others are off-curve
98 points.
99
100 This method implements TrueType-style curves, breaking up curves
101 using 'implied points': between each two consequtive off-curve points,
102 there is one implied point exactly in the middle between them. See
103 also decomposeQuadraticSegment().
104
105 The last argument (normally the on-curve point) may be None.
106 This is to support contours that have NO on-curve points (a rarely
107 seen feature of TrueType outlines).
108 """
109 raise NotImplementedError
110
111 def closePath(self) -> None:
112 """Close the current sub path. You must call either pen.closePath()
113 or pen.endPath() after each sub path.
114 """
115 pass
116
117 def endPath(self) -> None:
118 """End the current sub path, but don't close it. You must call
119 either pen.closePath() or pen.endPath() after each sub path.
120 """
121 pass
122
123 def addComponent(
124 self,
125 glyphName: str,
126 transformation: Tuple[float, float, float, float, float, float],
127 ) -> None:
128 """Add a sub glyph. The 'transformation' argument must be a 6-tuple
129 containing an affine transformation, or a Transform object from the
130 fontTools.misc.transform module. More precisely: it should be a
131 sequence containing 6 numbers.
132 """
133 raise NotImplementedError
134
135 def addVarComponent(
136 self,
137 glyphName: str,
138 transformation: DecomposedTransform,
139 location: Dict[str, float],
140 ) -> None:
141 """Add a VarComponent sub glyph. The 'transformation' argument
142 must be a DecomposedTransform from the fontTools.misc.transform module,
143 and the 'location' argument must be a dictionary mapping axis tags
144 to their locations.
145 """
146 # GlyphSet decomposes for us
147 raise AttributeError
148
149
150class NullPen(AbstractPen):
151 """A pen that does nothing."""
152
153 def moveTo(self, pt):
154 pass
155
156 def lineTo(self, pt):
157 pass
158
159 def curveTo(self, *points):
160 pass
161
162 def qCurveTo(self, *points):
163 pass
164
165 def closePath(self):
166 pass
167
168 def endPath(self):
169 pass
170
171 def addComponent(self, glyphName, transformation):
172 pass
173
174 def addVarComponent(self, glyphName, transformation, location):
175 pass
176
177
178class LoggingPen(LogMixin, AbstractPen):
179 """A pen with a ``log`` property (see fontTools.misc.loggingTools.LogMixin)"""
180
181 pass
182
183
184class MissingComponentError(KeyError):
185 """Indicates a component pointing to a non-existent glyph in the glyphset."""
186
187
188class DecomposingPen(LoggingPen):
189 """Implements a 'addComponent' method that decomposes components
190 (i.e. draws them onto self as simple contours).
191 It can also be used as a mixin class (e.g. see ContourRecordingPen).
192
193 You must override moveTo, lineTo, curveTo and qCurveTo. You may
194 additionally override closePath, endPath and addComponent.
195
196 By default a warning message is logged when a base glyph is missing;
197 set the class variable ``skipMissingComponents`` to False if you want
198 all instances of a sub-class to raise a :class:`MissingComponentError`
199 exception by default.
200 """
201
202 skipMissingComponents = True
203 # alias error for convenience
204 MissingComponentError = MissingComponentError
205
206 def __init__(
207 self,
208 glyphSet,
209 *args,
210 skipMissingComponents=None,
211 reverseFlipped=False,
212 **kwargs,
213 ):
214 """Takes a 'glyphSet' argument (dict), in which the glyphs that are referenced
215 as components are looked up by their name.
216
217 If the optional 'reverseFlipped' argument is True, components whose transformation
218 matrix has a negative determinant will be decomposed with a reversed path direction
219 to compensate for the flip.
220
221 The optional 'skipMissingComponents' argument can be set to True/False to
222 override the homonymous class attribute for a given pen instance.
223 """
224 super(DecomposingPen, self).__init__(*args, **kwargs)
225 self.glyphSet = glyphSet
226 self.skipMissingComponents = (
227 self.__class__.skipMissingComponents
228 if skipMissingComponents is None
229 else skipMissingComponents
230 )
231 self.reverseFlipped = reverseFlipped
232
233 def addComponent(self, glyphName, transformation):
234 """Transform the points of the base glyph and draw it onto self."""
235 from fontTools.pens.transformPen import TransformPen
236
237 try:
238 glyph = self.glyphSet[glyphName]
239 except KeyError:
240 if not self.skipMissingComponents:
241 raise MissingComponentError(glyphName)
242 self.log.warning("glyph '%s' is missing from glyphSet; skipped" % glyphName)
243 else:
244 pen = self
245 if transformation != Identity:
246 pen = TransformPen(pen, transformation)
247 if self.reverseFlipped:
248 # if the transformation has a negative determinant, it will
249 # reverse the contour direction of the component
250 a, b, c, d = transformation[:4]
251 det = a * d - b * c
252 if det < 0:
253 from fontTools.pens.reverseContourPen import ReverseContourPen
254
255 pen = ReverseContourPen(pen)
256 glyph.draw(pen)
257
258 def addVarComponent(self, glyphName, transformation, location):
259 # GlyphSet decomposes for us
260 raise AttributeError
261
262
263class BasePen(DecomposingPen):
264 """Base class for drawing pens. You must override _moveTo, _lineTo and
265 _curveToOne. You may additionally override _closePath, _endPath,
266 addComponent, addVarComponent, and/or _qCurveToOne. You should not
267 override any other methods.
268 """
269
270 def __init__(self, glyphSet=None):
271 super(BasePen, self).__init__(glyphSet)
272 self.__currentPoint = None
273
274 # must override
275
276 def _moveTo(self, pt):
277 raise NotImplementedError
278
279 def _lineTo(self, pt):
280 raise NotImplementedError
281
282 def _curveToOne(self, pt1, pt2, pt3):
283 raise NotImplementedError
284
285 # may override
286
287 def _closePath(self):
288 pass
289
290 def _endPath(self):
291 pass
292
293 def _qCurveToOne(self, pt1, pt2):
294 """This method implements the basic quadratic curve type. The
295 default implementation delegates the work to the cubic curve
296 function. Optionally override with a native implementation.
297 """
298 pt0x, pt0y = self.__currentPoint
299 pt1x, pt1y = pt1
300 pt2x, pt2y = pt2
301 mid1x = pt0x + 0.66666666666666667 * (pt1x - pt0x)
302 mid1y = pt0y + 0.66666666666666667 * (pt1y - pt0y)
303 mid2x = pt2x + 0.66666666666666667 * (pt1x - pt2x)
304 mid2y = pt2y + 0.66666666666666667 * (pt1y - pt2y)
305 self._curveToOne((mid1x, mid1y), (mid2x, mid2y), pt2)
306
307 # don't override
308
309 def _getCurrentPoint(self):
310 """Return the current point. This is not part of the public
311 interface, yet is useful for subclasses.
312 """
313 return self.__currentPoint
314
315 def closePath(self):
316 self._closePath()
317 self.__currentPoint = None
318
319 def endPath(self):
320 self._endPath()
321 self.__currentPoint = None
322
323 def moveTo(self, pt):
324 self._moveTo(pt)
325 self.__currentPoint = pt
326
327 def lineTo(self, pt):
328 self._lineTo(pt)
329 self.__currentPoint = pt
330
331 def curveTo(self, *points):
332 n = len(points) - 1 # 'n' is the number of control points
333 assert n >= 0
334 if n == 2:
335 # The common case, we have exactly two BCP's, so this is a standard
336 # cubic bezier. Even though decomposeSuperBezierSegment() handles
337 # this case just fine, we special-case it anyway since it's so
338 # common.
339 self._curveToOne(*points)
340 self.__currentPoint = points[-1]
341 elif n > 2:
342 # n is the number of control points; split curve into n-1 cubic
343 # bezier segments. The algorithm used here is inspired by NURB
344 # splines and the TrueType "implied point" principle, and ensures
345 # the smoothest possible connection between two curve segments,
346 # with no disruption in the curvature. It is practical since it
347 # allows one to construct multiple bezier segments with a much
348 # smaller amount of points.
349 _curveToOne = self._curveToOne
350 for pt1, pt2, pt3 in decomposeSuperBezierSegment(points):
351 _curveToOne(pt1, pt2, pt3)
352 self.__currentPoint = pt3
353 elif n == 1:
354 self.qCurveTo(*points)
355 elif n == 0:
356 self.lineTo(points[0])
357 else:
358 raise AssertionError("can't get there from here")
359
360 def qCurveTo(self, *points):
361 n = len(points) - 1 # 'n' is the number of control points
362 assert n >= 0
363 if points[-1] is None:
364 # Special case for TrueType quadratics: it is possible to
365 # define a contour with NO on-curve points. BasePen supports
366 # this by allowing the final argument (the expected on-curve
367 # point) to be None. We simulate the feature by making the implied
368 # on-curve point between the last and the first off-curve points
369 # explicit.
370 x, y = points[-2] # last off-curve point
371 nx, ny = points[0] # first off-curve point
372 impliedStartPoint = (0.5 * (x + nx), 0.5 * (y + ny))
373 self.__currentPoint = impliedStartPoint
374 self._moveTo(impliedStartPoint)
375 points = points[:-1] + (impliedStartPoint,)
376 if n > 0:
377 # Split the string of points into discrete quadratic curve
378 # segments. Between any two consecutive off-curve points
379 # there's an implied on-curve point exactly in the middle.
380 # This is where the segment splits.
381 _qCurveToOne = self._qCurveToOne
382 for pt1, pt2 in decomposeQuadraticSegment(points):
383 _qCurveToOne(pt1, pt2)
384 self.__currentPoint = pt2
385 else:
386 self.lineTo(points[0])
387
388
389def decomposeSuperBezierSegment(points):
390 """Split the SuperBezier described by 'points' into a list of regular
391 bezier segments. The 'points' argument must be a sequence with length
392 3 or greater, containing (x, y) coordinates. The last point is the
393 destination on-curve point, the rest of the points are off-curve points.
394 The start point should not be supplied.
395
396 This function returns a list of (pt1, pt2, pt3) tuples, which each
397 specify a regular curveto-style bezier segment.
398 """
399 n = len(points) - 1
400 assert n > 1
401 bezierSegments = []
402 pt1, pt2, pt3 = points[0], None, None
403 for i in range(2, n + 1):
404 # calculate points in between control points.
405 nDivisions = min(i, 3, n - i + 2)
406 for j in range(1, nDivisions):
407 factor = j / nDivisions
408 temp1 = points[i - 1]
409 temp2 = points[i - 2]
410 temp = (
411 temp2[0] + factor * (temp1[0] - temp2[0]),
412 temp2[1] + factor * (temp1[1] - temp2[1]),
413 )
414 if pt2 is None:
415 pt2 = temp
416 else:
417 pt3 = (0.5 * (pt2[0] + temp[0]), 0.5 * (pt2[1] + temp[1]))
418 bezierSegments.append((pt1, pt2, pt3))
419 pt1, pt2, pt3 = temp, None, None
420 bezierSegments.append((pt1, points[-2], points[-1]))
421 return bezierSegments
422
423
424def decomposeQuadraticSegment(points):
425 """Split the quadratic curve segment described by 'points' into a list
426 of "atomic" quadratic segments. The 'points' argument must be a sequence
427 with length 2 or greater, containing (x, y) coordinates. The last point
428 is the destination on-curve point, the rest of the points are off-curve
429 points. The start point should not be supplied.
430
431 This function returns a list of (pt1, pt2) tuples, which each specify a
432 plain quadratic bezier segment.
433 """
434 n = len(points) - 1
435 assert n > 0
436 quadSegments = []
437 for i in range(n - 1):
438 x, y = points[i]
439 nx, ny = points[i + 1]
440 impliedPt = (0.5 * (x + nx), 0.5 * (y + ny))
441 quadSegments.append((points[i], impliedPt))
442 quadSegments.append((points[-2], points[-1]))
443 return quadSegments
444
445
446class _TestPen(BasePen):
447 """Test class that prints PostScript to stdout."""
448
449 def _moveTo(self, pt):
450 print("%s %s moveto" % (pt[0], pt[1]))
451
452 def _lineTo(self, pt):
453 print("%s %s lineto" % (pt[0], pt[1]))
454
455 def _curveToOne(self, bcp1, bcp2, pt):
456 print(
457 "%s %s %s %s %s %s curveto"
458 % (bcp1[0], bcp1[1], bcp2[0], bcp2[1], pt[0], pt[1])
459 )
460
461 def _closePath(self):
462 print("closepath")
463
464
465if __name__ == "__main__":
466 pen = _TestPen(None)
467 pen.moveTo((0, 0))
468 pen.lineTo((0, 100))
469 pen.curveTo((50, 75), (60, 50), (50, 25), (0, 0))
470 pen.closePath()
471
472 pen = _TestPen(None)
473 # testing the "no on-curve point" scenario
474 pen.qCurveTo((0, 0), (0, 100), (100, 100), (100, 0), None)
475 pen.closePath()