• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1from array import array
2from typing import Any, Dict, Optional, Tuple
3from fontTools.misc.fixedTools import MAX_F2DOT14, floatToFixedToFloat
4from fontTools.misc.loggingTools import LogMixin
5from fontTools.pens.pointPen import AbstractPointPen
6from fontTools.misc.roundTools import otRound
7from fontTools.pens.basePen import LoggingPen, PenError
8from fontTools.pens.transformPen import TransformPen, TransformPointPen
9from fontTools.ttLib.tables import ttProgram
10from fontTools.ttLib.tables._g_l_y_f import Glyph
11from fontTools.ttLib.tables._g_l_y_f import GlyphComponent
12from fontTools.ttLib.tables._g_l_y_f import GlyphCoordinates
13
14
15__all__ = ["TTGlyphPen", "TTGlyphPointPen"]
16
17
18class _TTGlyphBasePen:
19    def __init__(
20        self,
21        glyphSet: Optional[Dict[str, Any]],
22        handleOverflowingTransforms: bool = True,
23    ) -> None:
24        """
25        Construct a new pen.
26
27        Args:
28            glyphSet (Dict[str, Any]): A glyphset object, used to resolve components.
29            handleOverflowingTransforms (bool): See below.
30
31        If ``handleOverflowingTransforms`` is True, the components' transform values
32        are checked that they don't overflow the limits of a F2Dot14 number:
33        -2.0 <= v < +2.0. If any transform value exceeds these, the composite
34        glyph is decomposed.
35
36        An exception to this rule is done for values that are very close to +2.0
37        (both for consistency with the -2.0 case, and for the relative frequency
38        these occur in real fonts). When almost +2.0 values occur (and all other
39        values are within the range -2.0 <= x <= +2.0), they are clamped to the
40        maximum positive value that can still be encoded as an F2Dot14: i.e.
41        1.99993896484375.
42
43        If False, no check is done and all components are translated unmodified
44        into the glyf table, followed by an inevitable ``struct.error`` once an
45        attempt is made to compile them.
46
47        If both contours and components are present in a glyph, the components
48        are decomposed.
49        """
50        self.glyphSet = glyphSet
51        self.handleOverflowingTransforms = handleOverflowingTransforms
52        self.init()
53
54    def _decompose(
55        self,
56        glyphName: str,
57        transformation: Tuple[float, float, float, float, float, float],
58    ):
59        tpen = self.transformPen(self, transformation)
60        getattr(self.glyphSet[glyphName], self.drawMethod)(tpen)
61
62    def _isClosed(self):
63        """
64        Check if the current path is closed.
65        """
66        raise NotImplementedError
67
68    def init(self) -> None:
69        self.points = []
70        self.endPts = []
71        self.types = []
72        self.components = []
73
74    def addComponent(
75        self,
76        baseGlyphName: str,
77        transformation: Tuple[float, float, float, float, float, float],
78        identifier: Optional[str] = None,
79        **kwargs: Any,
80    ) -> None:
81        """
82        Add a sub glyph.
83        """
84        self.components.append((baseGlyphName, transformation))
85
86    def _buildComponents(self, componentFlags):
87        if self.handleOverflowingTransforms:
88            # we can't encode transform values > 2 or < -2 in F2Dot14,
89            # so we must decompose the glyph if any transform exceeds these
90            overflowing = any(
91                s > 2 or s < -2
92                for (glyphName, transformation) in self.components
93                for s in transformation[:4]
94            )
95        components = []
96        for glyphName, transformation in self.components:
97            if glyphName not in self.glyphSet:
98                self.log.warning(f"skipped non-existing component '{glyphName}'")
99                continue
100            if self.points or (self.handleOverflowingTransforms and overflowing):
101                # can't have both coordinates and components, so decompose
102                self._decompose(glyphName, transformation)
103                continue
104
105            component = GlyphComponent()
106            component.glyphName = glyphName
107            component.x, component.y = (otRound(v) for v in transformation[4:])
108            # quantize floats to F2Dot14 so we get same values as when decompiled
109            # from a binary glyf table
110            transformation = tuple(
111                floatToFixedToFloat(v, 14) for v in transformation[:4]
112            )
113            if transformation != (1, 0, 0, 1):
114                if self.handleOverflowingTransforms and any(
115                    MAX_F2DOT14 < s <= 2 for s in transformation
116                ):
117                    # clamp values ~= +2.0 so we can keep the component
118                    transformation = tuple(
119                        MAX_F2DOT14 if MAX_F2DOT14 < s <= 2 else s
120                        for s in transformation
121                    )
122                component.transform = (transformation[:2], transformation[2:])
123            component.flags = componentFlags
124            components.append(component)
125        return components
126
127    def glyph(self, componentFlags: int = 0x4) -> Glyph:
128        """
129        Returns a :py:class:`~._g_l_y_f.Glyph` object representing the glyph.
130        """
131        if not self._isClosed():
132            raise PenError("Didn't close last contour.")
133        components = self._buildComponents(componentFlags)
134
135        glyph = Glyph()
136        glyph.coordinates = GlyphCoordinates(self.points)
137        glyph.coordinates.toInt()
138        glyph.endPtsOfContours = self.endPts
139        glyph.flags = array("B", self.types)
140        self.init()
141
142        if components:
143            # If both components and contours were present, they have by now
144            # been decomposed by _buildComponents.
145            glyph.components = components
146            glyph.numberOfContours = -1
147        else:
148            glyph.numberOfContours = len(glyph.endPtsOfContours)
149            glyph.program = ttProgram.Program()
150            glyph.program.fromBytecode(b"")
151
152        return glyph
153
154
155class TTGlyphPen(_TTGlyphBasePen, LoggingPen):
156    """
157    Pen used for drawing to a TrueType glyph.
158
159    This pen can be used to construct or modify glyphs in a TrueType format
160    font. After using the pen to draw, use the ``.glyph()`` method to retrieve
161    a :py:class:`~._g_l_y_f.Glyph` object representing the glyph.
162    """
163
164    drawMethod = "draw"
165    transformPen = TransformPen
166
167    def _addPoint(self, pt: Tuple[float, float], onCurve: int) -> None:
168        self.points.append(pt)
169        self.types.append(onCurve)
170
171    def _popPoint(self) -> None:
172        self.points.pop()
173        self.types.pop()
174
175    def _isClosed(self) -> bool:
176        return (not self.points) or (
177            self.endPts and self.endPts[-1] == len(self.points) - 1
178        )
179
180    def lineTo(self, pt: Tuple[float, float]) -> None:
181        self._addPoint(pt, 1)
182
183    def moveTo(self, pt: Tuple[float, float]) -> None:
184        if not self._isClosed():
185            raise PenError('"move"-type point must begin a new contour.')
186        self._addPoint(pt, 1)
187
188    def curveTo(self, *points) -> None:
189        raise NotImplementedError
190
191    def qCurveTo(self, *points) -> None:
192        assert len(points) >= 1
193        for pt in points[:-1]:
194            self._addPoint(pt, 0)
195
196        # last point is None if there are no on-curve points
197        if points[-1] is not None:
198            self._addPoint(points[-1], 1)
199
200    def closePath(self) -> None:
201        endPt = len(self.points) - 1
202
203        # ignore anchors (one-point paths)
204        if endPt == 0 or (self.endPts and endPt == self.endPts[-1] + 1):
205            self._popPoint()
206            return
207
208        # if first and last point on this path are the same, remove last
209        startPt = 0
210        if self.endPts:
211            startPt = self.endPts[-1] + 1
212        if self.points[startPt] == self.points[endPt]:
213            self._popPoint()
214            endPt -= 1
215
216        self.endPts.append(endPt)
217
218    def endPath(self) -> None:
219        # TrueType contours are always "closed"
220        self.closePath()
221
222
223class TTGlyphPointPen(_TTGlyphBasePen, LogMixin, AbstractPointPen):
224    """
225    Point pen used for drawing to a TrueType glyph.
226
227    This pen can be used to construct or modify glyphs in a TrueType format
228    font. After using the pen to draw, use the ``.glyph()`` method to retrieve
229    a :py:class:`~._g_l_y_f.Glyph` object representing the glyph.
230    """
231
232    drawMethod = "drawPoints"
233    transformPen = TransformPointPen
234
235    def init(self) -> None:
236        super().init()
237        self._currentContourStartIndex = None
238
239    def _isClosed(self) -> bool:
240        return self._currentContourStartIndex is None
241
242    def beginPath(self, identifier: Optional[str] = None, **kwargs: Any) -> None:
243        """
244        Start a new sub path.
245        """
246        if not self._isClosed():
247            raise PenError("Didn't close previous contour.")
248        self._currentContourStartIndex = len(self.points)
249
250    def endPath(self) -> None:
251        """
252        End the current sub path.
253        """
254        # TrueType contours are always "closed"
255        if self._isClosed():
256            raise PenError("Contour is already closed.")
257        if self._currentContourStartIndex == len(self.points):
258            raise PenError("Tried to end an empty contour.")
259        self.endPts.append(len(self.points) - 1)
260        self._currentContourStartIndex = None
261
262    def addPoint(
263        self,
264        pt: Tuple[float, float],
265        segmentType: Optional[str] = None,
266        smooth: bool = False,
267        name: Optional[str] = None,
268        identifier: Optional[str] = None,
269        **kwargs: Any,
270    ) -> None:
271        """
272        Add a point to the current sub path.
273        """
274        if self._isClosed():
275            raise PenError("Can't add a point to a closed contour.")
276        if segmentType is None:
277            self.types.append(0)  # offcurve
278        elif segmentType in ("qcurve", "line", "move"):
279            self.types.append(1)  # oncurve
280        elif segmentType == "curve":
281            raise NotImplementedError("cubic curves are not supported")
282        else:
283            raise AssertionError(segmentType)
284
285        self.points.append(pt)
286